crossmate

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

GamePushCredentialsTests.swift (3239B)


      1 import CryptoKit
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 /// The per-game push credential and its publish signer. The signer's encoding
      8 /// is a contract with the push worker (`Workers/push-worker.js`), which
      9 /// re-derives the identical HMAC from the secret it holds for the credID.
     10 @Suite("Game push credentials")
     11 struct GamePushCredentialsTests {
     12 
     13     @Test("encode/decode round-trips")
     14     func roundTrip() throws {
     15         let creds = try GamePushCredentials.fresh()
     16         let decoded = try #require(GamePushCredentials.decode(creds.encoded()))
     17         #expect(decoded == creds)
     18     }
     19 
     20     @Test("fresh mints a 256-bit secret and unique credentials")
     21     func freshProperties() throws {
     22         let a = try GamePushCredentials.fresh()
     23         let b = try GamePushCredentials.fresh()
     24         #expect(Data(base64URLEncoded: a.secret)?.count == 32)
     25         #expect(a.credID != b.credID)
     26         #expect(a.secret != b.secret)
     27     }
     28 
     29     @Test("decode tolerates nil and malformed input")
     30     func decodeLenient() {
     31         #expect(GamePushCredentials.decode(nil) == nil)
     32         #expect(GamePushCredentials.decode("not json") == nil)
     33     }
     34 
     35     @Test("signature is deterministic and sensitive to every input")
     36     func signatureProperties() throws {
     37         let credID = UUID()
     38         let secret = try GamePushCredentials.fresh().secret
     39         let payload = GamePushSigner.signaturePayload(
     40             credID: credID, bodyHash: "bh", timestamp: "100", nonce: "n1"
     41         )
     42         let sig = try GamePushSigner.signature(payload: payload, secret: secret)
     43         #expect(try GamePushSigner.signature(payload: payload, secret: secret) == sig)
     44 
     45         // A different secret or any changed payload field yields a different MAC.
     46         let otherSecret = try GamePushCredentials.fresh().secret
     47         #expect(try GamePushSigner.signature(payload: payload, secret: otherSecret) != sig)
     48         let otherPayload = GamePushSigner.signaturePayload(
     49             credID: credID, bodyHash: "bh", timestamp: "101", nonce: "n1"
     50         )
     51         #expect(try GamePushSigner.signature(payload: otherPayload, secret: secret) != sig)
     52 
     53         // URL-safe base64 (the worker compares it as a plain string).
     54         let allowed = CharacterSet(charactersIn:
     55             "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
     56         #expect(sig.unicodeScalars.allSatisfy(allowed.contains))
     57     }
     58 
     59     @Test("signer keys on the decoded secret bytes (worker parity)")
     60     func keyDerivationContract() throws {
     61         // A fixed key both sides can reproduce. Guards the contract that the
     62         // HMAC key is the base64url-*decoded* secret (not its UTF-8 bytes) and
     63         // the output is base64url — exactly what `hmacSHA256` does in the worker.
     64         let secret = Data(repeating: 7, count: 32).base64URLEncodedString()
     65         let payload = "crossmate-push-game-v1\ncred\nbody\n123\nnonce"
     66         let sig = try GamePushSigner.signature(payload: payload, secret: secret)
     67 
     68         let key = SymmetricKey(data: try #require(Data(base64URLEncoded: secret)))
     69         let mac = HMAC<SHA256>.authenticationCode(for: Data(payload.utf8), using: key)
     70         #expect(sig == Data(mac).base64URLEncodedString())
     71     }
     72 }