Basic Game/Server Authentication

Learn how to make communications between your game's client and server more secure

In our previous tutorial, we created a very simple server application that exposed endpoints to create and evolve Living Assets. The only problem is, those endpoints are completely exposed - if a malicious actor knows the basics of the JSON format, he could create an infinite number of infinitely evolved assets!

Clearly, in some form we need to authenticate these requests to prevent such attacks, and this tutorial we will do exactly that.

In a production environment, you would protect our endpoint with SSL (https), which means that packets in-transit become illegible to a 3rd party. But we need to go beyond such encryption - our goal is that only registered users performing actions in-game can either create or evolve Living Assets.

Specifically, we will build our authentication system around two core principles:

  1. Our game should be server authoritative when it comes to creating and upgrading assets. We must assume that game client binaries can and will be disassembled, and allowing them to request changes to assets, without server verification, is making things easy for malicious actors.

  2. We will use RSA encryption to encrypt messages before they leave the game application. This is necessary because, even when using SSL, a malicious actor could potentially use SSL-sniffing tactic to redirect all traffic from the game client to a malicious proxy server first, thus exposing the content of the message. Adding an extra encryption layer in the client binary mitigates this.

User authentication is an extremely important topic. The code in this tutorial is meant as an introductory guide. At the end of the tutorials we provide options for making our system even more secure; but we also recommend that a production deployment make use of a dedicated multiplayer framework which has user authentication built-in (such as Nakama).

One important element that this tutorial skips is the issue of user registration (creating a database of usernames, hashed emails/passwords etc.), which is beyond the scope of this tutorial. As a result, in the example code we will be hardcoding the hashed email of a single 'registered user', on the assumption that a user registration process, involving database access, will be added in a previous step.

Requirements

  • Basic knowledge of development in NodeJS

  • Basic knowledge of how POST operations work in RESTful applications.

  • Basic knowledge of RSA encryption and public/private key pairs.

  • A development machine with the latest version of NodeJS and Node Package Manager installed.

  • Having followed and completed the previous tutorial: Basic Living Assets Server, or downloaded the final code from the tutorial from our examples repository.

  • A sandbox universe (and associated private key) within the Living Assets API. If you do not have one of these, please contact us.

Overview

Let's consider the situation where the gamer has carried out an action which should change the properties of an asset. The game client must communicate that action to the server, which then updates the properties of the asset.

Our approach assumes that the game client has stored a public RSA key of the universe owner, which is used to encrypt messages that only the server can decrypt (using the private RSA key counterpart). We also assume that the server maintains some data regarding a registered user - in this case, the hashed email address of a user.

Note that we are using different key pairs for encrypting messages to the web3 keys we use for signing transactions. To avoid any confusion, in this article we will always specify that we are using an 'RSA key'.

With this in mind, our approach is the following:

  1. Game client wants to send a message to the server informing that a specific action has happened in the game.

  2. Game client encrypts this message with the server's public RSA key, adding hashed user data for authentication, and sends it to the server.

  3. Game server decrypts using the server's private RSA key, verifies the hashed user data, and performs any other validity checks.

  4. Game server assembles JSON to send to Living Assets API, and executes mutation as in previous tutorial.

This is an example of a simple server-authoritative approach to game logic - the client is simply reporting to the server the actions that the user has taken, and it is up to the server to determine the effect of those actions.

This approach also mitigates against a malicious user spamming the same encrypted message, in that the only result of doing so would be 'change' the asset repeatedly to the same values. Defining the list of actions possible within the game, and the effects of each action, is entirely down to each individual's game design.

In the tutorial, we'll only update the '/evolve/' route, and remove the '/create/' route completely. Asset creation is something you will likely want to keep as server authoritative (for example, in response to a purchase).

Creating the encryption keys

Use any method you like to create public and private RSA encryption keys. For example, from the terminal you can use:

openssl genrsa -out gameserverprivate.pem 2048
openssl rsa -in gameserverprivate.pem -outform PEM -pubout -out gameserverpublic.pem

The public key is exactly that - we will retain a copy of it in the binary of our game application. The private key should be stored securely on our server.

A simple test client

In the next tutorial, we will build our game client as a Unity application. For now, let's stick to NodeJS and create a very simple client application to test our server.

Create a new directory and run the following to get setup:

npm init
npm install request

Setup

Now create a new file, pbk.js, and a create a new variable where you can paste the text content of the gameserverpublic.pem file you just created:

exports.GAME_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArTtVgx+9+sQTt3qyTeGX
<snip multiple lines>
xwIDAQAB
-----END PUBLIC KEY-----
`;

Alternatively you can copy gameserverpublic.pem to your node project and read the file contents into a string at runtime.

Now create a new file, index.js, and insert the following code:

const request = require('request');
const crypto = require('node:crypto');
const { GAME_PUBLIC_KEY } = require('./pbk');

const USER_EMAIL = 'user@server.com';
const SALT = 'livingassets';
// this variable should be a specific code that only your server can understand
const ACTION_CODE = 'XYZ500';

// this is where we will be working from now one
const run = async () => {
    console.log('hello auth');
}

run();

As you can see in the code above, for this tutorial, we are sending to the server a specific 'action code' ("XYZ500"), which the server interprets in order to change an asset's properties.

Test our dummy client by running:

node index.js

The message that we will POST is a small JSON with the action code (hard-coded above), along with the hashed email of the user, which we add for increased security and traceability.

We will use RSA to encrypt our message, then package it into a string and send it via POST to our endpoint.

Modify the content of the run() function to the following:

const run = async () => {
    // hash the user's email with a salt
    const hashedUser = crypto.createHash('md5').update(USER_EMAIL + SALT).digest("hex");

    // create the message to be encrypted
    const messageJson = { user: hashedUser, action: ACTION_CODE };
    const message = JSON.stringify(messageJson);

    // convert to byte array, encrypt with the public key, and store as a hex string
    const buffer = Buffer.from(message);
    const encrypted = crypto.publicEncrypt({
        key: GAME_PUBLIC_KEY,
        padding: crypto.constants.RSA_PKCS1_PADDING,
    }, buffer);
    const encryptedMessage = encrypted.toString('hex');

    // POST the message to the server
    request.post(
        'http://localhost:3000/evolve/', // the url of the server when running
        { json: { message: encryptedMessage } },
        function (error, response, body) {
            if (error) console.log(error);
            else console.log(body);
        }
    );
}

The first few lines uses standard MD5 to hash the email with the salt, before assembling a JSON string to be encrypted.

We then use Node standard crypto library to encrypt this string, converting it first to a byte buffer, and afterwards into a hex string for transmission.

The final block uses the request library to post the encrypted message in json format.

Updating the server

Let's turn to the server we created in the last tutorial, either continuing from where you left off, or cloning into the repo of that tutorial and running npm i.

If you're continuing from the last tutorial, remove the '/create/' route from index.js, as discussed above. You should now have a single route, '/evolve/'.

Receiving and Decrypting the message from the client

Open gameserverprivate.pem, created in the previous step and copy the contents to a variable in a new file in your node project, pvk.js (or, as above, you could read in the pem file to a string at runtime if you desired):

exports.GAME_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtO1WDH736xBO3
<snip multiple lines>
9kPuDalB72oRksnUxkomTZ91
-----END PRIVATE KEY-----
`;

Now add two new dependencies variables at the top of index.js, one for our private key and the other for the default cryptography library included with node. Also add the variables that we will use to verify our received message:

// index.js
const crypto = require('node:crypto');
const { GAME_PRIVATE_KEY } = require('./pvk');
// ...
// the hash for these two values could be precomputed and stored securely
const USER_EMAIL = 'user@server.com';
const SALT = 'livingassets';
// the action
const ACTION_CODE = 'XYZ500';

Now modify the start '/evolve/' to the following:

app.post('/evolve/', async (request, response) => {
  // get the encrypted message from the body
  const { message } = request.body;

  // convert to a buffer and decrypt
  const buffer = Buffer.from(message, 'hex');
  const decrypted = crypto.privateDecrypt({
    key: GAME_PRIVATE_KEY,
    padding: crypto.constants.RSA_PKCS1_PADDING,
  }, buffer);
  // parse the result into a JSON
  const decryptedJSON = JSON.parse(decrypted.toString('utf8'));

  // check that our user is correct
  const hash = crypto.createHash('md5').update(USER_EMAIL + SALT).digest('hex');
  if (hash !== decryptedJSON.user) {
    response.send('Unauthorised user');
    return;
  }
  // ...

Here we are decrypting the received message, before checking that the hashed user email and salt match with what we have stored.

For clarity we have not put any error checking in this code, but in a production environment you should use multiple try...catch blocks to catch any errors, for example if received data is not in correct format, if the decryption fails, if the JSON parsing fails etc., and return relevant messages to the client.

Parsing the action code and updating an asset

In this tutorial, we are going to use the action code 'XYZ500' as a trigger to update the properties of a single particular asset, which we store as JSON variable in a new file, gameasset.js:

// gameasset.js
exports.ASSET_JSON = {
  asset: '2659135364631074528293600266911586821553740380548290008617451061479',
  props: {
    name: 'Supercool Dragon',
    description: 'Legendary creature that loves fire.',
    image: 'ipfs://QmPCHHeL1i6ZCUnQ1RdvQ5G3qccsjgQF8GkJrWAm54kdtB',
    animation_url: 'ipfs://QmefzYXCtUXudCy9LYjU4biapHJiP26EGYS8hQjpei472j',
    attributes: [
      {
        trait_type: 'Rarity',
        value: 'Legendary',
      },
      {
        trait_type: 'Level',
        value: 10,
      },
      {
        trait_type: 'Weight',
        value: 300.9,
      },
    ],
  },
  metadata: {
    info: 'which is private',
  },
};

And don't forget to import this into index.js:

// index.js
// ...
const { GAME_ASSET } = require('./gameasset');
// ...

For simplicity in the tutorial we are hardcoding data that could be set dynamically. For example, the asset properties could be stored in a database and updated dynamically, or we could query the Living Assets API to obtain them.

We will add code to the '/evolve/' route to check the action code, set the properties of the asset JSON according to our game design rules, and then send the mutation as we have done in our previous tutorial:

  //index.js evolve route
  // ... previous code
  
  // parse the action code and return a message if unknown
  switch (decryptedJSON.action) {
    case ACTION_CODE:
      // this value set according to the game's design mechanics
      GAME_ASSET.props.attributes[2].value = 420.6;
      break;
    default:
      response.send('Unknown action');
      return;
  }

  // get the relevant fields from the GAME_ASSET
  const { asset, props, metadata } = GAME_ASSET;
  
  // ... continue to fetch nonce and sign/send mutation

Now let's start our server running, before switching to the client and running it. The server should successfully execute the mutation and send the result to the client, which will print it to the console:

We can also now check in the Living Asset Scan app to confirm the result of the mutation:

Increased Security

Our implementation has placed some basic security which prevents simple man-in-the-middle attacks and message repeat attacks. However, one flaw is that the request to update the asset is simply encrypted with the universe owner public key - which, by definition, is public. Furthermore, should the game binary be disassembled, our action codes might be revealed, and it would be possible for a malicious actor to recreate everything in a dummy client and spam the server to falsely change asset properties. In this section we discuss methods of protecting against such an attack vector.

Password authentication for all requests

While our example in this tutorial checks a registered user's email, one simple but effective measure would be to check that user's password also, in a similar way that we check the hashed email.

Additional authorisation handshake & shared secret key.

We can create bidirectional, single-use secrets, to further obfuscate our requests from the client. For example:

  • the client requests an authorisation token, via a message using the server public key.

  • the server creates a new, random key, and sends the public component to the client, encrypted with the user's public key. The private element of the new random key can be stored on the server (and, if required, marked as 'invalid' at any moment). The server can optionally send more data to the client in this step, for example a nonce value.

  • the client encrypts the actual request with the new, secret key, and makes the request as above. This is then decrypted with the stored temporary key.

The advantage of this approach is that the actual communication of the game action is done by a shared key - we can be certain that only the game client knows it, as it was encrypted with the users private key.

A more elaborate version of this approach, used by many AAA game studios, is to use SRP-6.

Nonces and other server-side checks

Ultimately, we must assume that any message from the game client is inherently untrustworthy, and implement as many server-side checks to validate its accuracy, as mentioned above. Some examples of this:

  • applying time-stamps to ensure that repeat requests are detected

  • using a randomly generated SALT will create different messages each time, so an attacker cannot reuse the same request, and will be more difficult for him to figure out the contest

  • using game logic to invalidate 'impossible' actions. For instance, having 1000 XP in the first level is not possible

  • passing a nonce value during the authorisation handshake, meaning that reauthorisation must be required

Ultimately, given that disassembly of the game client's code will always be possible, the more hoops we can put in place - and particularly, the more server-side checks we can put in place - the more secure our game will be.

Use SSL for transmission

Finally, it should go without saying that SSL encryption should be used for all communication!

Conclusion

In this tutorial, we have built a simple authentication system for our game server. We have tried to make our game server-authoritative, in that the game client simply informs the server what the gamer has done, and does specifically request changes.

We have tested our server with a very simple javascript client. In the next tutorial, we shall build this into a Unity application.

Last updated

freeverse.io