Having dealt with the intricacies of firebase, we’re ready to start implementing the cloud function side of our userless game service. I’ll try to stay focused on how a key pair is used to replace a password.

The first thing a player will have to do before they can play is create a device with a public key. That can be done with the following cloud function:

create_device.js

9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
module.exports = {
  schema: Joi.object().keys({
    public_key: Joi.string().required(),
    name: Joi.string().default("Anonymous Human"),
    signature: Joi.string().required()
  }),
  handler: async function createDevice(body, db) {
    const { public_key, signature, name } = body;
    // no device. validate against hard-coded string
    const verify = Crypto.createVerify('sha1');
    verify.update('hexicle:ahoy');
 
    if(!verify.verify(public_key, Buffer.from(signature, 'hex'))) {
      throw new Error("Failed to verify signature!", { signature, public_key });
    }
 
    const device_ref = db.ref('/devices');
 
    // createDevice doesn't consume a nonce, but it needs its first one set
    const nonce = await Nonce.compute();
    const new_device = await device_ref.push({ name, public_key, nonce });
    return { id: new_device.key, nonce };
  }
};

Now things are getting interesting!

Note that we are using sha1. When creating signatures, you can only sign messages up to the length of the cipher. Typically, since real messages are often longer than that, this is achieved by hashing the message before encrypting it. The signature is the output of this process. The C# RSACryptoServiceProvider is hard-coded to use SHA-1 for this purpose. If you want to use that implementation, you must use SHA-1 in your server code as well. That’s adequate for most cases but if you are handling sensitive data for some reason, you might consider using a longer hash.

At first glance, it might worry you that we compare against a hard-coded string hexicle:ahoy. If you are only attempting to verify that the sender controls the private key, it does not matter what value is signed as long as it’s a value that key pair has not likely signed and transmitted before. While it’s true that this invocation could be replayed by an attacker and it would be accepted, it would be very difficult to carry out and it would only result in a useless duplicate device record.

Since the existing user’s public key would have to be included in order for the string to validate, I could instead detect that the key already is in use and reject the request. I am going to have to clean up stale data from my database periodically anyway, so there’s no harm done by keeping the simpler implementation.

A couple other notes: this uses hex encoding for the signatures. Base64 would save a little payload size. I will probably change it eventually; the choice is arbitrary. Also, notice that our response contains a fresh nonce. The device will receive an updated nonce through the realtime db, but it cannot be trusted to have been updated at any given time. To alleviate this issue, we provide the new nonce directly in the response, and the client can immediately have confidence that the nonce is ready for the next invocation. You’ll see this pattern in all of these cloud functions.

From the client-side (Unity), that invocation looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    public async static Task SaveDevice(HexicleDevice device) {
      FirebaseFunctions functions = FirebaseFunctions.DefaultInstance;
      FirebaseDatabase database = FirebaseDatabase.DefaultInstance;
      HttpsCallableReference callable = functions.GetHttpsCallable("save_device");
 
      try {
        var request = new Dictionary<string ,string>();
        string message = "hexicle:ahoy";
 
        request["public_key"] = device.PublicKey;
        request["name"] = device.Name;
 
        if(device.Id != null) {
          message = string.Format("{0}:{1}:{2}", device.Id, device.Name, device.Nonce);
          request["id"] = device.Id;
        }
 
        request["signature"] = Sign(message);
        var result = await callable.CallAsync(request);
 
        var responseBody = result.Data as Dictionary<object ,object>;
        device.Id = (string)responseBody["id"];
        device.Nonce = (string)responseBody["nonce"];
 
        Debug.Log("Device ID: " + device.Id);
      } catch (AggregateException e) {
        Debug.LogWarning(e);
      }
 
      Debug.Log("Saving device JSON.");
      File.WriteAllText(GetDevicePath(), JsonConvert.SerializeObject(device));
    }
    // some WordPress bug really wants </object></string> here

Wait… save_device? I actually expose an “upsert” method, and then fan out the request to create and update. The implementation is trivial and available in the repository.

What’s this if(device.Id != null) business? That allows us to use this for existing and new devices. save_device will use the id to determine that this is an update, and require a more complete signature (with a nonce, so that nobody else can perform this same name change on your account later, should you decide to change it again).

Sign is implemented this way:

1
2
3
4
    public static string Sign(string message) {
      GetRSAPublicKey();
      return String.Join("", provider.SignData(Encoding.ASCII.GetBytes(message), new SHA1CryptoServiceProvider()).Select(b => b.ToString("x2")));
    }

GetRSAPublicKey invokes function shown in part 1 which populates a provider singleton, either from the XML file or a new one. It also subsequently generates a PEM-encoded public key (also a singleton) using the method kindly provided in this StackOverflow post. I’ve designed this function such that if these are already present, my function does very little. Only the provider is actually used to sign; the PEM-encoded public key is needed by the node.js crypto module for verifying signatures.

After this verification, we have a device record saved with a public key that the user can now use to sign all of the rest of their requests. We also saved a nonce on the device record that must be used to sign their next request (it’s literally just crypto.randomBytes), and sent it back in the response body so that the client can begin making signed requests without waiting for the realtime database to update the device locally.

Sweet! What’s mine say?

As I mentioned, the above method creates devices, but it can update them too. If it sends an Id, the save_device handler will invoke update_device instead. Corresponding to the above, this function expects a signature to be produced from a colon-delimited string of game id, name, and nonce. The implementation is almost identical to create_device, except that I use set (update) instead of push (insert), and expect a more secure signature.

Get a move on

I spent a fair amount of time thinking about how moves would be accepted and processed. For simplicity of server implementation, I decided on a design that would be completely agnostic on the server-side of the game rules. What that means is that any player can send a move at any time, and if they sign it correctly, the server will accept it and it will be sent to the other game clients. Those clients will simply recognize whether it’s that player’s turn at that point in the game, and determine whether to process the move.

I sacrificed a number of features by taking this approach, but none of them would require a user to implement. It does require the server to understand the rules of the game, which was more than I wanted to build into this service right now. That could still be done in the future, but since it would have been a lot more work, and since I’m blazing a trail, I decided to keep it simple and build an implementation that can make sense outside of my game context.

Since the rules of the game were irrelevant to the service, I could simply serialize the entire action data into a single blob property inside of another attribute (action_data). Then that blob can be used to produce a signature, which will prevent tampering.

Because of that, you would probably guess that the server side is simply a secure signature verification followed by a push to the moves collection. However, there’s a catch. Part of my game design allows for AI players. I intend for AI players to participate in online games. If the server does not know the rules, this means the AI moves must be made by human players’ game clients. THAT means the server has to accept signatures from any player in the game when it is an AI player’s turn. Here’s how that looks:

move.js

10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
module.exports = {
  schema: Joi.object().keys({
    game_id: Joi.string().guid().required(),
    seat: Joi.number().required(),
    action_data: Joi.string().required(),
    signature: Joi.string().required()
  }),
  handler: async (body, db) => {
    const { game_id, seat, action_data, signature } = body;
    const action_json = JSON.parse(action_data);
    let actingPlayer = await db.ref(`/games/${game_id}/players/${seat}`).once('value');
    let movingPlayer = actingPlayer;
    if(seat != action_json.action.Player) {
      // a player (acting player) has submitted a move on behalf of another player (moving player)
      movingPlayer = await db.ref(`/games/${game_id}/players/${action_json.action.Player}`).once('value')
    }
    movingPlayer = movingPlayer.val();
    actingPlayer = actingPlayer.val();
 
    if(movingPlayer == null) {
      throw new Error("The seat specified in the action is empty. Perhaps you meant to join_game?");
    }
 
    if(actingPlayer == null) {
      throw new Error("The seat specified in the function arguments is empty. No key to use.");
    }
 
    const { val: device, ref: deviceRef } = await existing(actingPlayer.device_id, db);
 
    // type == 0 means human
    if(movingPlayer.type == 0 && seat != action_json.action.Player) {
      throw new Error('Player attempting to play on behalf of another human.');
    }
 
    const Verify = Crypto.createVerify('sha1');
    Verify.update(`${game_id}:${action_data}:${device.nonce}`);
 
    const nonce = await Nonce.refresh(deviceRef);
    if(!Verify.verify(device.public_key, Buffer.from(signature, 'hex'))) {
      throw new Error('Failed to verify signature.', { game_id, action_data, device });
    }
 
    const new_move = await db.ref(`/games/${game_id}/moves`)
      .push({ seat, action_data });
 
    return { id: new_move.key, nonce };
  }
};

So, we have to fetch both players, one by the seat in the payload and the other by the player given in the move. Unfortunately this meant I had to pry apart the move JSON (line 19), but only to read a value. After fetching the players, I just check the signature against the human player and then allow the move if either the two players are the same (seat != action_json.action.Player), or the “moving player” is not human.

I hope this has shown you that we don’t need personal information in order to make software work, even if your app needs to keep track of everyone’s own data. It will still almost always be in business requirements and therefore you may still have to do it, but for independent projects that just want to provide some service to people, ask yourself what your app would look like if your users could be completely anonymous.

Pleasantly, this userless design does not appear to itself inhibit any features whatsoever, and I can imagine scores of tools that would only facilitate this further until we don’t even have to spend any time on it. In part 3 I’ll dive deeper into some of the gotchas that I ran into, the tradeoffs that I made to get here, how I might do this differently next time or in the next iteration, and what I think could be done to put the wind at the backs of people who would like to take this sort of approach.

Categories: FirebaseNode.jsUnity