Up until now, everything had been satisfactory…

The Problem

I’ve been creating a sort of digital board game for Android in Unity. Having arrived at the part where I needed to implement multiplayer, I was faced with a dilemma. How will these players find one anothers’ games and connect to play? How will they invite friends? How will I identify them? How will their moves synchronize between devices? Am I going to ask them to create a user account?

Unity itself provides matchmaking services, and components that can send game data between players. However I get the sense that it’s aimed at real-time multiplayer, while I need the game to live beyond the play session. After some deliberation I wound up choosing Firebase (actually, I chose Cloud Firestore, and then Jon Skeet broke my heart with a lack of support for Unity, so I switched to Firebase) because the Realtime Database seemed like a perfect match for the features I needed. My game is turn-based, and I want to allow “play by e-mail” asynchronous use cases, meaning the current player should be allowed to play while the others are offline.

Completing the puzzle

That still left me with a bunch of questions around user creation and authentication. Unfortunately, not only is there no one-size-fits-all solution, it did not seem that there was one that fit me. One thing is for sure: I’m sick of asking users to create an account. In a GDPR era, especially, I want as little of my users’ personal information as possible. I don’t want to be a target for hackers. If someone is interested in stealing data from my board game database, I’ve done something horribly wrong. I also just don’t want to create YALF (yet another login form).

It was out of this same furnace that OAuth and SAML were forged, along with passwordless schemes that just send you a magic login link (Slack). Even those I find to be inappropriate for any application that genuinely has no interest in a user’s e-mail address, because all of them require me to receive it. We can do better.

To that end, in Hexicle I have implemented userless authentication – through the use of public-key cryptography to send and verify signatures that will identify the same visitor on each visit with certainty, and without even the slightest possibility of exposure to any of their personal information.

The only reason the rest of the internet is still using e-mail, I think, is because of tutorials like this one (strongly implying that authentication and e-mail addresses go together like peanut butter and jelly):

Firebase Authentication Tutorial

One star, Google! ONE STAR!!

Anyway, I’m bucking the status quo and putting my foot down because I can. I do not need my players’ e-mail addresses and neither do many of you. We only need to correlate each request to the set of resources that the end-user owns. They can each be identified by automatically generated secure (and anonymous) cryptographic keys.

High-Level Implementation

This solution is similar to the concept of client certificates. I didn’t use actual client certificates because of the complexity of creating a CA and trust chain for certs that need to be created with no user interaction. I also don’t want to conflate security, privacy, and proof. My keys are strictly for proving I’m the guy seated in the game. Google’s certificates are for making sure the correct information arrives without tampering. Theirs needs a trust chain. Mine doesn’t.

I’m definitely not the first to implement this. I might be the first foolish enough to implement it entirely within the application itself. I’ve seen an implementation that integrated with OpenID Connect. I’m not sure I completely understand it, but potentially, federating this functionality as a custom authentication mechanism for OpenID Connect makes a lot of sense, provided that I as a consumer can totally avoid exposure to personal information. Ideally it should be possible for users to have an “account” without ever providing any such information in the first place. Implementing it in this way would provide a solution to the problem of having to transfer keys between devices, since the device’s key now represents the federated account rather than the app account.

In my case, I have no need to authenticate outside of my Android app. I do not intend for users to be able to “log in” on multiple devices to connect to the same set of games. There is also no OpenID Connect implementation that I would like to integrate with, and if there was, or if I were to create one, that’s an additional failure mode for my app. So instead I elected to just create keys and sign/verify requests manually. It’s worth mentioning that the multi-device problem could also be solved cryptographically, through a signed link-account request and signed approval from the other device. In a future utopia there might also exist other third-party utilities that synchronize keys between your devices.

While an in-app implementation means my solution can’t realistically be standardized, it’s also simpler than a bespoke integration like OIDC — so, I’m going to share it with you. I’m hopeful this will help to open a broader dialogue about this topic and how it might be applicable to any type of application deployed anywhere that doesn’t need or want user data.

Currently, I’m doing it all by hand. It is my expectation that these steps can be simplified and further secured by standards, utilities, browser extensions, and hardware implementations.

The first step is for the client app (either a native app or a native browser) to generate an RSA key pair. In Unity, this is simple. I load it from a local file, or create it and save it if it doesn’t exist:

1
2
3
4
5
6
7
provider = new RSACryptoServiceProvider();
if(File.Exists(keyPath)) {
  string contents = File.ReadAllText(keyPath);
  provider.FromXmlString(contents);
} else {
  File.WriteAllText(keyPath, provider.ToXmlString(true));
}

These keys each represent a person, and we need to consistently associate certain data with that particular person, without knowing who they are. In my current implementation I’ve ultimately tied the account to the device itself, so my firebase collections are called “games” and “devices”, to make this explicit for now. In a future version I may allow users to associate devices with one another, so that the keys are interchangeable, but that’s definitely out of scope for now. The first thing we definitely need is to allow people to create devices and games.

A firebase-shaped hurdle

Unfortunately, since firebase’s security rules cascade, if we allow anybody to create a game, we will also allow them to modify any game. Therefore, we can’t use those security rules for my app unless we are okay with having rules in two places. Instead, I allow all users to read all games, and nobody can write. Our cloud functions will do all of the writing. Then the clients will read the changes from the realtime db and process them. This doesn’t make for a very interesting firebase config, but here it is:

1
2
3
4
5
6
7
8
9
10
11
12
{
  "rules": {
    "games": {
      ".read": true,
      ".write": false
    },
    "devices": {
      ".read": true,
      ".write": false
    },
  }
}

It may seem that this will prevent us from writing, but actually it won’t because our cloud functions can use the firebase-admin API. In other words, they can be made to circumvent the security rules entirely and authenticate by virtue of the fact that they were put there by me while I was authenticated.

This simple configuration is accompanied, at present, by four cloud functions. Each function manipulates the state of the realtime database, which is subsequently picked up and processed by the client only once it has been processed by the server and sent back as an event. Generally the database writes are insert-only, with a few exceptions that I will call out.

One of the gotchas that caught me rather early on is that firebase cloud functions have a very RPC-like contract. You can return 200 for success or 400 for validation failure, or your function can throw or return a rejected promise. Your body must contain either data or error properties (both is allowed). If you do much of anything else, firebase will get really confused and give strange errors. So I created a thin abstraction that would take a simple async function and provide these guarantees, along with logging errors to the firebase console:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const functions = require('firebase-functions');
const Joi = require('joi');
 
const validate = async (body, schema) => {
  const result = Joi.validate(body, { data: schema }, { abortEarly: false });
  if(result.error) { throw result.error; }
  return result.value.data;
}
 
module.exports = (func, db) => functions.https.onRequest(async (req, res) => {
  let body;
  try {
    body = await validate(req.body, func.schema);
  } catch (error) {
    console.warn("Validation failed.", error);
    res.status(400).send({ error });
    return;
  }
 
  let data = await func.handler(body, db);
  res.status(200).send({ data });
});

Any failure after validation will be an unhandled rejection, which will log the error in the console. I don’t know what would happen if you re-throw the validation failure, but don’t do that — the 400 response is the correct mechanism. Just log the failure if you need to see it (I know I do).

I’m almost surprised Google doesn’t offer an interface like this for a quick start. I’m sure part of the reason is that it probably doesn’t work very well with more traditional forms of authentication. For my case, it simplified things a great deal, even though it’s a simplification of the actual protocol spec. I’m licensing it with MIT, so please take it if it’ll help you.

This has abstracted away every single implementation detail of firebase. Now our functions can look like this:

1
2
3
4
5
6
7
8
9
10
const Joi = require('joi');
 
module.exports = {
  schema: Joi.object().keys(/* your Joi schema */),
  handler: async (body, db) => {
    // body is guaranteed to meet your schema and populated with Joi defaults.
    // return whatever you want to be sent back in the data property. 
    // or throw and you'll send a 500.
  }
}

Nice.

In my next post, I’ll dive into the data model and my actual cloud functions’ implementations.