Architecture Improvements

Published by

on

Architecture changes

Over on mastodon (you know, where all the cool people hang out), June from Redpoint Games reached out to me with an alternate and much better design for dealing with client encryption keys. By the way, check them out if you need matchmaking code, trust me that stuff is SUPER COMPLEX, like NP Hard level complex.

Anyways, she even drew up a doper diagram!

The idea is now we don’t have to have any direct communications between auth servers and our game servers. This is great for scalability as well as simplifies the game server code because we don’t need to synchronize keys between game/auth servers any more.

Before I dive in to how this works, I do want to mention it’s far more complex when you do something like this in production. Mainly, you will need to involve load balancers, TLS between clients and non-game servers, and Key Management Systems (KMS). KMS is important because you never want to store these keys on these servers directly, but rather have the game and auth servers call out a KMS to retrieve them and only store them in memory. A compromised game server could still happen, but the whole point of a KMS is for auditability. You can be alerted if/when that happens.

ANYWAYS, for this new architecture, lets look at it from a local development scenario instead, because that’s what shape the code is in now.

I decided to build the AuthServer in Go because I can knock out Go code pretty quickly. I use gin gonic for the framework and just have a simple auth handler spit out our signed messages. Here’s the full code:

func Login(c *gin.Context) {
    userID, err := getUserId(c.PostForm("user"), c.PostForm("password"))
    if err != nil {
        c.Status(http.StatusUnauthorized)
        return
    }

    userPublicKey, err := hex.DecodeString(c.PostForm("pubkey"))
    if err != nil {
        log.Printf("failed to decode users public key")
        c.Status(http.StatusBadRequest)
        return
    }

    if len(userPublicKey) != ed25519.PublicKeySize {
        log.Printf("invalid public key length")
        c.Status(http.StatusBadRequest)
        return
    }

    // Create a new buffer to store our message type, userid, a timestamp, users temp pubkey and signature
    bufferSize := services.SizeUint8 + services.SizeUint64 + services.SizeInt64 + ed25519.PublicKeySize + ed25519.SignatureSize

    buf := services.NewBuffer(bufferSize)
    buf.WriteUint8(PubKeyMessage)
    buf.WriteUint64(userID)
    buf.WriteInt64(time.Now().Add(time.Minute).Unix())
    buf.WriteBytes(userPublicKey)

    message := buf.GetWrittenBytes()

	signature := ed25519.Sign(TestPrivateKey, message)

    buf.WriteBytes(signature)

    message = buf.GetWrittenBytes()
    c.Data(http.StatusOK, "application/octet-stream", message)
}

The user passes in their username, password, and an ephemeral public key. Provided they are legit, we get the userId back from our “login” process. Next we build out a simple byte buffer and write in the PubKeyMessage (an uint8 enum value denoting message type). We write the userId, a timestamp, and the user’s public key they passed us. If you want to see what the Buffer code looks like, check the repository. We then use ed25519 to sign that entire buffer up until that point. Finally, we append that signature to the end of our buffer and send it back to the client. So we have in our response:

| MsgType | UserId | Timestamp | UsersPublicKey | Signature |

Note that this entire response is not encrypted, because it doesn’t need to be.

So, our client now has all the details they need to pass to our game server to get it to generate the encryption key to use for gameplay. The game server will be sent this data by the client, extract the message and signature, then verify the message using the signature:

/**
 * @brief Takes in a signed message and signed message length to verify that a user has been authorized by
 * our AuthServer.
 *
 * @param SignedMessage
 * @param SignedMessageLen
 * @return true
 * @return false
 */
bool Cryptor::VerifyUser(const unsigned char *SignedMessage, const unsigned long long SignedMessageLen) const
{
	auto MessageLen = SignedMessageLen-crypto_sign_BYTES;
	if (SignedMessageLen <= 0)
	{
		return false;
	}

	// need to use a vector to handle variable size array
	std::vector<unsigned char>Message(MessageLen);
	// copy just the message part into Message
	std::memcpy(Message.data(), SignedMessage, MessageLen);
	// copy signature from our SignedMessage
	auto Signature = std::make_unique<unsigned char[]>(crypto_sign_BYTES);
	std::memcpy(Signature.get(), SignedMessage+MessageLen, crypto_sign_BYTES);
	// Verify
	return crypto_sign_verify_detached(Signature.get(), Message.data(), MessageLen, TestServerPubKey) == 0;
}

This is pretty much it, we copy the message parts to the Message buffer, and extract the Signature from the SignedMessage and have libsodium’s crypto_sign_verify_detached do the validation for us.

REMINDER: ed25519 is for SIGNATURES not encryption (That’s x25519, or Curve25519), so all of this message is clear text. Which is fine! Because all we care about is being able to validate it came from the AuthServer.

Now to make sure this interoperates with C++, I wrote a simple test case that validates:

TEST_CASE( "Tests Verifying Message From Go Auth Server", "[crypto]" )
{
    // Example message taken from the Go Auth Server
   const unsigned char AuthServerMessage[] = {
    0x00, 0xb3, 0xb4, 0xf3, 0xc4, 0x3f, 0xb3, 0x4d, 0xd3, 0x73, 0xfb, 0xa8, 0x64, 0x00, 0x00, 0x00,
    0x00, 0x65, 0x98, 0x3c, 0x98, 0x65, 0x81, 0x83, 0xbe, 0xae, 0x5b, 0x3a, 0x4c, 0x22, 0xaf, 0xb9,
    0xef, 0x5f, 0xa9, 0x75, 0xf7, 0x25, 0x2c, 0x21, 0x88, 0x87, 0x7d, 0x34, 0x24, 0x1e, 0x1d, 0x05,
    0x40, 0xf8, 0x95, 0x48, 0x3f, 0x03, 0x7a, 0xca, 0xf9, 0x38, 0xd9, 0x8c, 0x81, 0xf5, 0x11, 0x76,
    0x71, 0xf8, 0x51, 0xc5, 0xe9, 0x8d, 0xd4, 0xcc, 0x49, 0xca, 0x5d, 0x99, 0x56, 0x3b, 0x07, 0xda,
    0xc1, 0xf5, 0x64, 0x82, 0x19, 0xe0, 0x89, 0x0f, 0x5d, 0x68, 0x1a, 0xef, 0xd2, 0x2e, 0xe2, 0x3e,
    0x1f, 0x20, 0x07, 0xd3, 0xe4, 0x64, 0xde, 0x9d, 0x19, 0x6a, 0x45, 0x9f, 0x77, 0x91, 0x53, 0xd7,
    0x0c,
   };

    auto Crypt = crypto::Cryptor();
    auto SignedMessageLen = sizeof(uint8_t)+sizeof(uint64_t)+sizeof(int64_t)+crypto_sign_ed25519_PUBLICKEYBYTES+crypto_sign_BYTES;

    REQUIRE(true == Crypt.VerifyUser(AuthServerMessage, SignedMessageLen));
}

I haven’t written the rest yet, but it should be pretty straight forward to now generate a new key specific to this user and this session. We can then use their public key to encrypt this key and send it back to the client. The client can then start encrypting game packets with this shared key.

So that’s it for now, soon I hope to have everything wired up where the client authenticates, then connects to the the game server, gets a key, and starts encrypting packets! Then I can finally do what I set out to do, which is actually benchmark how much of a perf hit encrypting/decrypting these game packets will be.

Until then!

Note that for some reason Go doesn’t have a way to convert ed25519 keys (for signing) to Curve25519 keys (for encryption), but since libsodium supports converting my test code just generates a sample ed25519 keypair.