Designing a Distributed System for an Online Multiplayer Game — Security Patch (Part 9)
This is part number nine of the Garage series, you can access all parts at the end of this post.
In this part, we will enhance the system's security.
In the past architecture, the game manager generated a new asymmetric RSA key for each game and passed it to the game server pod using environment variables. The game client downloads the game server's public key from the game manager API server to communicate in a secure channel. Only the users that have access to the game can download the public key from the game manager. Also, it's transferred over secure TLS.
There are two security concerns with this architecture:
Key Management
The game manager generates a new RSA key for each game, encrypts it with a shared key (AES), and stores it in the Redis for further requests.
The game client requests the game manager to download the public key over TLS.
So as you see, the game manager uses Redis as a cache to manage the keys. It's not a secure way. If the Redis and the shared key get compromised, the attacker gains access to all games' private keys!
Solution
There is no need to store the game's private key; keeping the public key is enough. Because the game manager API server only serves the game server's public key.
Man In Middle Attack
The public key is transferred over TLS, but It's vulnerable. In this attack, the attacker positions himself between the client and the server. When the client sends a request to the server to handshake, the attacker replays the request to the server and downloads the valid public key. Then it returns its owned public key to the client. The client encrypts its shared secret (AES key) with the attacker's public key and sends it to the server. The attacker decrypts the request via its private key and gets access to the client's secret key. Thus, the attacker can pack the request again, encrypt it with the server's public key, and send it to the server. Finally, the attacker can decrypt the server handshake response with the session ID and BOOM!
Solution
To cope with this attack, we should follow the same manner used in the browsers. Certificate authority! The game manager generates a new private key for each game and signs it with the master secret key. The master public key is stored in the game client to validate the game server's public key. Thus the attacker's public key is invalid because it has not a valid signature from the master private key! Puff!
Instead of using the raw public key, the game manager API server returns a digital certificate (including the public key and digital signature) to validate the public key root.
Implementation
AWS also provides a private CA service. So we can store the master private key, an intermediate issuer certificate to issue the game servers' certificates. But I was not able to implement and test it locally because the localstack
doesn't support ACM-PC yet.
So I defined the CA manager as an interface to have different implementations. For now, I implemented the private CA in the game manager.
type CA interface {
IssueCertificate(ctx context.Context, config NewCertConfig) (*Certificate, error)
CertPEM() []byte
}
type NewCertConfig struct {
OrganizationalUnit []string
Country []string
Locality []string
SerialNumber *big.Int
KeyID []byte
NotBefore time.Time
NotAfter time.Time
PrivateKey *rsa.PrivateKey
CommonName string
}
type Certificate struct {
PrivateKey *rsa.PrivateKey
PEM []byte
}
There is a master private key to issue the root certificate. To start, we need to define an x509 certificate with some information like organization, expiration, etc. Then we use the master private key to sign this certificate (including the master public key).
type LocalCAManager struct {
privateKey *rsa.PrivateKey
cert *x509.Certificate
certBytes []byte
}
type CAManagerConfig struct {
Organization []string
Country []string
Locality []string
PrivateKey *rsa.PrivateKey
SerialNumber *big.Int
NotBefore time.Time
NotAfter time.Time
}
func NewLocalCAManager(config CAManagerConfig) (*LocalCAManager, error) {
if config.PrivateKey == nil {
return nil, errors.New("invalid private key")
}
caManager := LocalCAManager{
privateKey: config.PrivateKey,
cert: &x509.Certificate{
SerialNumber: config.SerialNumber,
Subject: pkix.Name{
Organization: config.Organization,
Country: config.Country,
Locality: config.Locality,
},
NotBefore: config.NotBefore,
NotAfter: config.NotAfter,
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
},
}
var err error
caManager.certBytes, err = x509.CreateCertificate(rand.Reader, caManager.cert, caManager.cert, &caManager.privateKey.PublicKey, caManager.privateKey)
if err != nil {
return nil, err
}
return &caManager, nil
}
Now, we generate a new random private key for each game and initialize a new certificate with the root certificate as its parent. Then we sign this certificate and the public key using the master private key.
func (l *LocalCAManager) IssueCertificate(ctx context.Context, config NewCertConfig) (*Certificate, error) {
var cert Certificate
if config.PrivateKey == nil {
reader := rand.Reader
var err error
cert.PrivateKey, err = rsa.GenerateKey(reader, defaultKeySize)
if err != nil {
return nil, err
}
}
x509Cert := &x509.Certificate{
SerialNumber: config.SerialNumber,
Subject: pkix.Name{
Organization: l.cert.Issuer.Organization,
OrganizationalUnit: config.OrganizationalUnit,
Country: config.Country,
Locality: config.Locality,
CommonName: config.CommonName,
},
NotBefore: config.NotBefore,
NotAfter: config.NotAfter,
SubjectKeyId: config.KeyID,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
certBytes, err := x509.CreateCertificate(rand.Reader, x509Cert, l.cert, &cert.PrivateKey.PublicKey, l.privateKey)
if err != nil {
return nil, err
}
certPEM := new(bytes.Buffer)
pem.Encode(certPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
cert.PEM = certPEM.Bytes()
return &cert, nil
}
Thus we generate a digital certificate per game server, including the hierarchy certificates and the server public key, signed by the master private key.
Finally, the root certificate (including the master public key) is stored as the Certificate Authority in the game client.
After downloading the game server’s certificate, the game client (Unity — C#) first validates the certificate’s signature and then checks the hierarchy tree to validate the root.
private bool ValidateCertificate(X509Certificate2 serverCert)
{
X509Chain chain = new X509Chain();
// The online or offline revocation is not needed
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
// By default, the Build method validates the certificate using system certificates, so set the VerificationFlags to AllowUnknownCertificateAuthority for private CA
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
chain.ChainPolicy.VerificationTime = DateTime.Now;
chain.ChainPolicy.UrlRetrievalTimeout = new TimeSpan(0, 0, 0);
// Add the root here. If any intermediate certificate exists, it must be added from bottom to top to ExtraStore as well. e.g. intermediate2, intermediate1, root
chain.ChainPolicy.ExtraStore.Add(RootCA);
var validCert = chain.Build(serverCert)
if (!validCert)
{
throw new Exception("server certificate is invalid");
}
var validChain = chain.ChainElements
.Cast<X509ChainElement>()
.Any(x => x.Certificate.Thumbprint == authority.Thumbprint);
if (!validChain)
{
throw new Exception("trust chain is invalid. thumbprints did not match.");
}
return true;
}
After the validation, the game client ensures that the public key is valid and signed by the root. It can encrypt its secrets and send them back to the game server to handle the handshake part securely.