196 lines
5.8 KiB
JavaScript
196 lines
5.8 KiB
JavaScript
/* Apple Game Center Auth
|
|
https://developer.apple.com/documentation/gamekit/gklocalplayer/1515407-generateidentityverificationsign#discussion
|
|
|
|
const authData = {
|
|
publicKeyUrl: 'https://valid.apple.com/public/timeout.cer',
|
|
timestamp: 1460981421303,
|
|
signature: 'PoDwf39DCN464B49jJCU0d9Y0J',
|
|
salt: 'saltST==',
|
|
bundleId: 'com.valid.app'
|
|
id: 'playerId',
|
|
};
|
|
*/
|
|
|
|
const { Parse } = require('parse/node');
|
|
const crypto = require('crypto');
|
|
const https = require('https');
|
|
const { pki } = require('node-forge');
|
|
const ca = { cert: null, url: null };
|
|
const cache = {}; // (publicKey -> cert) cache
|
|
|
|
function verifyPublicKeyUrl(publicKeyUrl) {
|
|
try {
|
|
const regex = /^https:\/\/(?:[-_A-Za-z0-9]+\.){0,}apple\.com\/.*\.cer$/;
|
|
return regex.test(publicKeyUrl);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function convertX509CertToPEM(X509Cert) {
|
|
const pemPreFix = '-----BEGIN CERTIFICATE-----\n';
|
|
const pemPostFix = '-----END CERTIFICATE-----';
|
|
|
|
const base64 = X509Cert;
|
|
const certBody = base64.match(new RegExp('.{0,64}', 'g')).join('\n');
|
|
|
|
return pemPreFix + certBody + pemPostFix;
|
|
}
|
|
|
|
async function getAppleCertificate(publicKeyUrl) {
|
|
if (!verifyPublicKeyUrl(publicKeyUrl)) {
|
|
throw new Parse.Error(
|
|
Parse.Error.OBJECT_NOT_FOUND,
|
|
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
|
|
);
|
|
}
|
|
if (cache[publicKeyUrl]) {
|
|
return cache[publicKeyUrl];
|
|
}
|
|
const url = new URL(publicKeyUrl);
|
|
const headOptions = {
|
|
hostname: url.hostname,
|
|
path: url.pathname,
|
|
method: 'HEAD',
|
|
};
|
|
const cert_headers = await new Promise((resolve, reject) =>
|
|
https.get(headOptions, res => resolve(res.headers)).on('error', reject)
|
|
);
|
|
const validContentTypes = ['application/x-x509-ca-cert', 'application/pkix-cert'];
|
|
if (
|
|
!validContentTypes.includes(cert_headers['content-type']) ||
|
|
cert_headers['content-length'] == null ||
|
|
cert_headers['content-length'] > 10000
|
|
) {
|
|
throw new Parse.Error(
|
|
Parse.Error.OBJECT_NOT_FOUND,
|
|
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
|
|
);
|
|
}
|
|
const { certificate, headers } = await getCertificate(publicKeyUrl);
|
|
if (headers['cache-control']) {
|
|
const expire = headers['cache-control'].match(/max-age=([0-9]+)/);
|
|
if (expire) {
|
|
cache[publicKeyUrl] = certificate;
|
|
// we'll expire the cache entry later, as per max-age
|
|
setTimeout(() => {
|
|
delete cache[publicKeyUrl];
|
|
}, parseInt(expire[1], 10) * 1000);
|
|
}
|
|
}
|
|
return verifyPublicKeyIssuer(certificate, publicKeyUrl);
|
|
}
|
|
|
|
function getCertificate(url, buffer) {
|
|
return new Promise((resolve, reject) => {
|
|
https
|
|
.get(url, res => {
|
|
const data = [];
|
|
res.on('data', chunk => {
|
|
data.push(chunk);
|
|
});
|
|
res.on('end', () => {
|
|
if (buffer) {
|
|
resolve({ certificate: Buffer.concat(data), headers: res.headers });
|
|
return;
|
|
}
|
|
let cert = '';
|
|
for (const chunk of data) {
|
|
cert += chunk.toString('base64');
|
|
}
|
|
const certificate = convertX509CertToPEM(cert);
|
|
resolve({ certificate, headers: res.headers });
|
|
});
|
|
})
|
|
.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function convertTimestampToBigEndian(timestamp) {
|
|
const buffer = Buffer.alloc(8);
|
|
|
|
const high = ~~(timestamp / 0xffffffff);
|
|
const low = timestamp % (0xffffffff + 0x1);
|
|
|
|
buffer.writeUInt32BE(parseInt(high, 10), 0);
|
|
buffer.writeUInt32BE(parseInt(low, 10), 4);
|
|
|
|
return buffer;
|
|
}
|
|
|
|
function verifySignature(publicKey, authData) {
|
|
const verifier = crypto.createVerify('sha256');
|
|
verifier.update(authData.playerId, 'utf8');
|
|
verifier.update(authData.bundleId, 'utf8');
|
|
verifier.update(convertTimestampToBigEndian(authData.timestamp));
|
|
verifier.update(authData.salt, 'base64');
|
|
|
|
if (!verifier.verify(publicKey, authData.signature, 'base64')) {
|
|
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Apple Game Center - invalid signature');
|
|
}
|
|
}
|
|
|
|
function verifyPublicKeyIssuer(cert, publicKeyUrl) {
|
|
const publicKeyCert = pki.certificateFromPem(cert);
|
|
if (!ca.cert) {
|
|
throw new Parse.Error(
|
|
Parse.Error.OBJECT_NOT_FOUND,
|
|
'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.'
|
|
);
|
|
}
|
|
try {
|
|
if (!ca.cert.verify(publicKeyCert)) {
|
|
throw new Parse.Error(
|
|
Parse.Error.OBJECT_NOT_FOUND,
|
|
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
|
|
);
|
|
}
|
|
} catch (e) {
|
|
throw new Parse.Error(
|
|
Parse.Error.OBJECT_NOT_FOUND,
|
|
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
|
|
);
|
|
}
|
|
return cert;
|
|
}
|
|
|
|
// Returns a promise that fulfills if this user id is valid.
|
|
async function validateAuthData(authData) {
|
|
if (!authData.id) {
|
|
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Apple Game Center - authData id missing');
|
|
}
|
|
authData.playerId = authData.id;
|
|
const publicKey = await getAppleCertificate(authData.publicKeyUrl);
|
|
return verifySignature(publicKey, authData);
|
|
}
|
|
|
|
// Returns a promise that fulfills if this app id is valid.
|
|
async function validateAppId(appIds, authData, options = {}) {
|
|
if (!options.rootCertificateUrl) {
|
|
options.rootCertificateUrl =
|
|
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem';
|
|
}
|
|
if (ca.url === options.rootCertificateUrl) {
|
|
return;
|
|
}
|
|
const { certificate, headers } = await getCertificate(options.rootCertificateUrl, true);
|
|
if (
|
|
headers['content-type'] !== 'application/x-pem-file' ||
|
|
headers['content-length'] == null ||
|
|
headers['content-length'] > 10000
|
|
) {
|
|
throw new Parse.Error(
|
|
Parse.Error.OBJECT_NOT_FOUND,
|
|
'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.'
|
|
);
|
|
}
|
|
ca.cert = pki.certificateFromPem(certificate);
|
|
ca.url = options.rootCertificateUrl;
|
|
}
|
|
|
|
module.exports = {
|
|
validateAppId,
|
|
validateAuthData,
|
|
cache,
|
|
};
|