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 }