How to authenticate the GKLocalPlayer on my 'third party server'?

Solution 1:

Here is a C# WebApi server side version:

public class GameCenterController : ApiController
{
    // POST api/gamecenter
    public HttpResponseMessage Post(GameCenterAuth data)
    {
        string token;
        if (ValidateSignature(data, out token))
        {
            return Request.CreateResponse(HttpStatusCode.OK, token);
        }
        return Request.CreateErrorResponse(HttpStatusCode.Forbidden, string.Empty);
    }

    private bool ValidateSignature(GameCenterAuth auth, out string token)
    {
        try
        {
            var cert = GetCertificate(auth.PublicKeyUrl);
            if (cert.Verify())
            {
                var csp = cert.PublicKey.Key as RSACryptoServiceProvider;
                if (csp != null)
                {
                    var sha256 = new SHA256Managed();
                    var sig = ConcatSignature(auth.PlayerId, auth.BundleId, auth.Timestamp, auth.Salt);
                    var hash = sha256.ComputeHash(sig);

                    if (csp.VerifyHash(hash, CryptoConfig.MapNameToOID("SHA256"), Convert.FromBase64String(auth.Signature)))
                    {
                        // Valid user.
                        // Do server related user management stuff.
                        return true;
                    }
                }
            }

            // Failure
            token = null;
            return false;
        }
        catch (Exception ex)
        {
            // Log the error
            token = null;
            return false;
        }
    }

    private static byte[] ToBigEndian(ulong value)
    {
        var buffer = new byte[8];
        for (int i = 0; i < 8; i++)
        {
            buffer[7 - i] = unchecked((byte)(value & 0xff));
            value = value >> 8;
        }
        return buffer;
    }

    private X509Certificate2 GetCertificate(string url)
    {
        var client = new WebClient();
        var rawData = client.DownloadData(url);
        return new X509Certificate2(rawData);
    }

    private byte[] ConcatSignature(string playerId, string bundleId, ulong timestamp, string salt)
    {
        var data = new List<byte>();
        data.AddRange(Encoding.UTF8.GetBytes(playerId));
        data.AddRange(Encoding.UTF8.GetBytes(bundleId));
        data.AddRange(ToBigEndian(timestamp));
        data.AddRange(Convert.FromBase64String(salt));
        return data.ToArray();
    }
}


public class GameCenterAuth
{
    public string PlayerId { get; set; }
    public string BundleId { get; set; }
    public string Name { get; set; }
    public string PublicKeyUrl { get; set; }
    public string Signature { get; set; }
    public string Salt { get; set; }
    public ulong Timestamp { get; set; }
}

Solution 2:

Here is how you can authenticate using objective c. If you need it in another language should be trivial to translate.

-(void)authenticate
{
    __weak GKLocalPlayer *localPlayer = [GKLocalPlayer localPlayer];
    localPlayer.authenticateHandler = ^(UIViewController *viewController, NSError *error)
    {
        if(viewController)
        {
            [[[UIApplication sharedApplication] keyWindow].rootViewController presentViewController:viewController animated:YES completion:nil];
        }
        else if(localPlayer.isAuthenticated == YES)
        {
            [localPlayer generateIdentityVerificationSignatureWithCompletionHandler:^(NSURL *publicKeyUrl, NSData *signature, NSData *salt, uint64_t timestamp, NSError *error) {

                if(error != nil)
                {
                    return; //some sort of error, can't authenticate right now
                }

                [self verifyPlayer:localPlayer.playerID publicKeyUrl:publicKeyUrl signature:signature salt:salt timestamp:timestamp];


            }];
        }
        else
        {
            NSLog(@"game center disabled");
        }
    };
}

-(void)verifyPlayer:(NSString *)playerID publicKeyUrl:(NSURL *)publicKeyUrl signature:(NSData *)signature salt:(NSData *)salt timestamp:(uint64_t)timestamp
{
    //get certificate
    NSData *certificateData = [NSData dataWithContentsOfURL:publicKeyUrl];

    //build payload
    NSMutableData *payload = [[NSMutableData alloc] init];
    [payload appendData:[playerID dataUsingEncoding:NSASCIIStringEncoding]];
    [payload appendData:[[[NSBundle mainBundle] bundleIdentifier] dataUsingEncoding:NSASCIIStringEncoding]];

    uint64_t timestampBE = CFSwapInt64HostToBig(timestamp);
    [payload appendBytes:&timestampBE length:sizeof(timestampBE)];
    [payload appendData:salt];

    //sign
    SecCertificateRef certificateFromFile = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData); // load the certificate
    SecPolicyRef secPolicy = SecPolicyCreateBasicX509();

    SecTrustRef trust;
    OSStatus statusTrust = SecTrustCreateWithCertificates( certificateFromFile, secPolicy, &trust);
    if(statusTrust != errSecSuccess)
    {
        NSLog(@"could not create trust");
        return;
    }

    SecTrustResultType resultType;
    OSStatus statusTrustEval =  SecTrustEvaluate(trust, &resultType);
    if(statusTrustEval != errSecSuccess)
    {
        NSLog(@"could not evaluate trust");
        return;
    }

    if(resultType != kSecTrustResultProceed && resultType != kSecTrustResultRecoverableTrustFailure)
    {
        NSLog(@"server can not be trusted");
        return;
    }

    SecKeyRef publicKey = SecTrustCopyPublicKey(trust);
    uint8_t sha256HashDigest[CC_SHA256_DIGEST_LENGTH];
    CC_SHA256([payload bytes], (CC_LONG)[payload length], sha256HashDigest);

    //check to see if its a match
    OSStatus verficationResult = SecKeyRawVerify(publicKey,  kSecPaddingPKCS1SHA256, sha256HashDigest, CC_SHA256_DIGEST_LENGTH, (const uint8_t *)[signature bytes], [signature length]);

    CFRelease(publicKey);
    CFRelease(trust);
    CFRelease(secPolicy);
    CFRelease(certificateFromFile);
    if (verficationResult == errSecSuccess)
    {
        NSLog(@"Verified");
    }
    else
    {
        NSLog(@"Danger!!!");
    }
}

EDIT:

as of March 2nd 2015, apple now uses SHA256 instead of SHA1 on the certificate. https://devforums.apple.com/thread/263789?tstart=0

Solution 3:

It took me a lot of time to implement it in PHP. Now I would like to share my result.

Documentation

You can find a very simple documentation at Apple: https://developer.apple.com/library/ios/documentation/GameKit/Reference/GKLocalPlayer_Ref/index.html#//apple_ref/occ/instm/GKLocalPlayer/generateIdentityVerificationSignatureWithCompletionHandler

[...]

  1. Use the publicKeyURL on the third party server to download the public key.
  2. Verify with the appropriate signing authority that the public key is signed by Apple.
  3. Retrieve the player’s playerID and bundleID.
  4. Concatenate into a data buffer the following information, in the order listed:
    • The playerID parameter in UTF-8 format
    • The bundleID parameter in UTF-8 format
    • The timestamp parameter in Big-Endian UInt-64 format
    • The salt parameter
  5. Generate a SHA-256 hash value for the buffer.
  6. Using the public key downloaded in step 3, verify that the hash value generated in step 7 matches the signature parameter provided by the API.

Notice! Number 7 is a trap in PHP that cost me hours. You have to pass only the raw concatenated string to the openssl_verify() function.

The update from Jul 9 2014 in the question How to authenticate the GKLocalPlayer on my 'third party server' using PHP? helped me to find the problem.

Final Source

<?php
// signature, publicKeyUrl, timestamp and salt are included in the base64/json data you will receive by calling generateIdentityVerificationSignatureWithCompletionHandler.

$timestamp = $params["timestamp"]; // e.g. 1447754520194
$user_id = $params["user_id"]; // e.g. G:20010412315
$bundle_id = "com.example.test";
$public_key_url = $params["publicKeyUrl"]; // e.g. https://static.gc.apple.com/public-key/gc-prod-2.cer
$salt = base64_decode($params["salt"]); // Binary
$signature = base64_decode($params["signature"]); // Binary

// Timestamp is unsigned 64-bit integer big endian
$highMap = 0xffffffff00000000;
$lowMap = 0x00000000ffffffff;
$higher = ($timestamp & $highMap) >>32;
$lower = $timestamp & $lowMap;
$timestamp = pack('NN', $higher, $lower);

// Concatenate the string
$data = $user_id . $bundle_id . $timestamp . $salt;

// ATTENTION!!! Do not hash it! $data = hash("sha256", $packed);

// Fetch the certificate. This is dirty because it is neither cached nor verified that the url belongs to Apple.
$ssl_certificate = file_get_contents($public_key_url);

$pem = chunk_split(base64_encode($ssl_certificate), 64, "\n");
$pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";

// it is also possible to pass the $pem string directly to openssl_verify
if (($pubkey_id = openssl_pkey_get_public($pem)) === false) {
    echo "invalid public key\n";
    exit;
}

// Verify that the signature is correct for $data
$verify_result = openssl_verify($data, $signature, $pubkey_id, OPENSSL_ALGO_SHA256);

openssl_free_key($pubkey_id);

switch($verify_result) {
  case 1:
    echo "Signature is ok.\n";
    break;
  case 0:
    echo "Signature is wrong.\n";
    break;
  default:
    echo "An error occurred.\n";
    break;
}

Solution 4:

Thanks, @odyth. Thanks, @Lionel. I want to add Python version (based on yours) here. It has minor flaw - Apple certificate is not verified - there is no such API at pyOpenSSL binding.

import urllib2
import OpenSSL
import struct

def authenticate_game_center_user(gc_public_key_url, app_bundle_id, gc_player_id, gc_timestamp, gc_salt, gc_unverified_signature):
    apple_cert = urllib2.urlopen(gc_public_key_url).read()
    gc_pkey_certificate = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, apple_cert)

    payload = gc_player_id.encode('UTF-8') + app_bundle_id.encode('UTF-8') + struct.pack('>Q', int(gc_timestamp)) + gc_salt

    try:
        OpenSSL.crypto.verify(gc_pkey_certificate, gc_unverified_signature, payload, 'sha1')
        print 'Signature verification is done. Success!'
    except Exception as res:
        print res

public_key_url = 'https://sandbox.gc.apple.com/public-key/gc-sb.cer'
player_GC_ID = 'G:1870391344'
timestamp = '1382621610281'
your_app_bundle_id = 'com.myapp.bundle_id'

with open('./salt.dat', 'rb') as f_salt:
    with open('./signature.dat', 'rb') as f_sign:
        authenticate_game_center_user(public_key_url, your_app_bundle_id, player_GC_ID, timestamp, f_salt.read(), f_sign.read())

Solution 5:

Adding an answer for Python, but using PyCrypto 2.6 (Which is the Google App Engine solution). Also note that verification of the public certificate after downloading is not done here, similar to the python answer above using OpenSSL. Is this step really necessary anyway? If we check that the public key URL is going to an apple domain and it's using ssl (https), doesn't that mean that it's protected from man-in-the-middle attacks?

Anyway, here is the code. Note the binary text is reconverted to binary before concatenation and use. Also I had to update my local python installation to use PyCrypto 2.6 before this would work:

from Crypto.PublicKey import RSA 
from Crypto.Signature import PKCS1_v1_5
from Crypto.Hash import SHA
from base64 import b64decode 
from Crypto.Util.asn1 import DerSequence
from binascii import a2b_base64
import struct
import urlparse

def authenticate_game_center_user(gc_public_key_url, app_bundle_id, gc_player_id, gc_timestamp, gc_salt, gc_unverified_signature):

    apple_cert = urllib2.urlopen(gc_public_key_url).read()

    #Verify the url is https and is pointing to an apple domain.
    parts = urlparse.urlparse(gc_public_key_url)
    domainName = ".apple.com"
    domainLocation = len(parts[1]) - len(domainName)
    actualLocation = parts[1].find(domainName)
    if parts[0] != "https" or domainName not in parts[1] or domainLocation != actualLocation:
        logging.warning("Public Key Url is invalid.")
        raise Exception

    cert = DerSequence()
    cert.decode(apple_cert)
    tbsCertificate = DerSequence()
    tbsCertificate.decode(cert[0])
    subjectPublicKeyInfo = tbsCertificate[6]

    rsakey = RSA.importKey(subjectPublicKeyInfo) 
    verifier = PKCS1_v1_5.new(rsakey) 

    payload = gc_player_id.encode('UTF-8') 
    payload = payload + app_bundle_id.encode('UTF-8')
    payload = payload + struct.pack('>Q', int(gc_timestamp))
    payload = payload + b64decode(gc_salt)
    digest = SHA.new(payload)

    if verifier.verify(digest, b64decode(gc_unverified_signature)):
        print "The signature is authentic."
    else:
        print "The signature is not authentic."