#include "Parsers.h" #include #include #include namespace Parsers { bool getDateTime(QJsonValue value, QDateTime & out) { if(!value.isString()) { return false; } out = QDateTime::fromString(value.toString(), Qt::ISODate); return out.isValid(); } bool getString(QJsonValue value, QString & out) { if(!value.isString()) { return false; } out = value.toString(); return true; } bool getNumber(QJsonValue value, double & out) { if(!value.isDouble()) { return false; } out = value.toDouble(); return true; } bool getNumber(QJsonValue value, int64_t & out) { if(!value.isDouble()) { return false; } out = (int64_t) value.toDouble(); return true; } bool getBool(QJsonValue value, bool & out) { if(!value.isBool()) { return false; } out = value.toBool(); return true; } /* { "IssueInstant":"2020-12-07T19:52:08.4463796Z", "NotAfter":"2020-12-21T19:52:08.4463796Z", "Token":"token", "DisplayClaims":{ "xui":[ { "uhs":"userhash" } ] } } */ // TODO: handle error responses ... /* { "Identity":"0", "XErr":2148916238, "Message":"", "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" } // 2148916233 = missing XBox account // 2148916238 = child account not linked to a family */ bool parseXTokenResponse(QByteArray & data, Katabasis::Token &output, QString name) { qDebug() << "Parsing" << name <<":"; #ifndef NDEBUG qDebug() << data; #endif QJsonParseError jsonError; QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); if(jsonError.error) { qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); return false; } auto obj = doc.object(); if(!getDateTime(obj.value("IssueInstant"), output.issueInstant)) { qWarning() << "User IssueInstant is not a timestamp"; return false; } if(!getDateTime(obj.value("NotAfter"), output.notAfter)) { qWarning() << "User NotAfter is not a timestamp"; return false; } if(!getString(obj.value("Token"), output.token)) { qWarning() << "User Token is not a string"; return false; } auto arrayVal = obj.value("DisplayClaims").toObject().value("xui"); if(!arrayVal.isArray()) { qWarning() << "Missing xui claims array"; return false; } bool foundUHS = false; for(auto item: arrayVal.toArray()) { if(!item.isObject()) { continue; } auto obj = item.toObject(); if(obj.contains("uhs")) { foundUHS = true; } else { continue; } // consume all 'display claims' ... whatever that means for(auto iter = obj.begin(); iter != obj.end(); iter++) { QString claim; if(!getString(obj.value(iter.key()), claim)) { qWarning() << "display claim " << iter.key() << " is not a string..."; return false; } output.extra[iter.key()] = claim; } break; } if(!foundUHS) { qWarning() << "Missing uhs"; return false; } output.validity = Katabasis::Validity::Certain; qDebug() << name << "is valid."; return true; } bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) { qDebug() << "Parsing Minecraft profile..."; #ifndef NDEBUG qDebug() << data; #endif QJsonParseError jsonError; QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); if(jsonError.error) { qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); return false; } auto obj = doc.object(); if(!getString(obj.value("id"), output.id)) { qWarning() << "Minecraft profile id is not a string"; return false; } if(!getString(obj.value("name"), output.name)) { qWarning() << "Minecraft profile name is not a string"; return false; } auto skinsArray = obj.value("skins").toArray(); for(auto skin: skinsArray) { auto skinObj = skin.toObject(); Skin skinOut; if(!getString(skinObj.value("id"), skinOut.id)) { continue; } QString state; if(!getString(skinObj.value("state"), state)) { continue; } if(state != "ACTIVE") { continue; } if(!getString(skinObj.value("url"), skinOut.url)) { continue; } if(!getString(skinObj.value("variant"), skinOut.variant)) { continue; } // we deal with only the active skin output.skin = skinOut; break; } auto capesArray = obj.value("capes").toArray(); QString currentCape; for(auto cape: capesArray) { auto capeObj = cape.toObject(); Cape capeOut; if(!getString(capeObj.value("id"), capeOut.id)) { continue; } QString state; if(!getString(capeObj.value("state"), state)) { continue; } if(state == "ACTIVE") { currentCape = capeOut.id; } if(!getString(capeObj.value("url"), capeOut.url)) { continue; } if(!getString(capeObj.value("alias"), capeOut.alias)) { continue; } output.capes[capeOut.id] = capeOut; } output.currentCape = currentCape; output.validity = Katabasis::Validity::Certain; return true; } namespace { // these skin URLs are for the MHF_Steve and MHF_Alex accounts (made by a Mojang employee) // they are needed because the session server doesn't return skin urls for default skins static const QString SKIN_URL_STEVE = "http://textures.minecraft.net/texture/1a4af718455d4aab528e7a61f86fa25e6a369d1768dcb13f7df319a713eb810b"; static const QString SKIN_URL_ALEX = "http://textures.minecraft.net/texture/83cee5ca6afcdb171285aa00e8049c297b2dbeba0efb8ff970a5677a1b644032"; bool isDefaultModelSteve(QString uuid) { // need to calculate *Java* hashCode of UUID // if number is even, skin/model is steve, otherwise it is alex // just in case dashes are in the id uuid.remove('-'); if (uuid.size() != 32) { return true; } // qulonglong is guaranteed to be 64 bits // we need to use unsigned numbers to guarantee truncation below qulonglong most = uuid.left(16).toULongLong(nullptr, 16); qulonglong least = uuid.right(16).toULongLong(nullptr, 16); qulonglong xored = most ^ least; return ((static_cast(xored >> 32)) ^ static_cast(xored)) % 2 == 0; } } /** Uses session server for skin/cape lookup instead of profile, because locked Mojang accounts cannot access profile endpoint (https://api.minecraftservices.com/minecraft/profile/) ref: https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape { "id": "", "name": "", "properties": [ { "name": "textures", "value": "" } ] } decoded base64 "value": { "timestamp": , "profileId": "", "profileName": "", "textures": { "SKIN": { "url": "" }, "CAPE": { "url": "" } } } */ bool parseMinecraftProfileMojang(QByteArray & data, MinecraftProfile &output) { qDebug() << "Parsing Minecraft profile..."; #ifndef NDEBUG qDebug() << data; #endif QJsonParseError jsonError; QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); if(jsonError.error) { qWarning() << "Failed to parse response as JSON: " << jsonError.errorString(); return false; } auto obj = doc.object(); if(!getString(obj.value("id"), output.id)) { qWarning() << "Minecraft profile id is not a string"; return false; } if(!getString(obj.value("name"), output.name)) { qWarning() << "Minecraft profile name is not a string"; return false; } auto propsArray = obj.value("properties").toArray(); QByteArray texturePayload; for( auto p : propsArray) { auto pObj = p.toObject(); auto name = pObj.value("name"); if (!name.isString() || name.toString() != "textures") { continue; } auto value = pObj.value("value"); if (value.isString()) { #if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) texturePayload = QByteArray::fromBase64(value.toString().toUtf8(), QByteArray::AbortOnBase64DecodingErrors); #else texturePayload = QByteArray::fromBase64(value.toString().toUtf8()); #endif } if (!texturePayload.isEmpty()) { break; } } if (texturePayload.isNull()) { qWarning() << "No texture payload data"; return false; } doc = QJsonDocument::fromJson(texturePayload, &jsonError); if(jsonError.error) { qWarning() << "Failed to parse response as JSON: " << jsonError.errorString(); return false; } obj = doc.object(); auto textures = obj.value("textures"); if (!textures.isObject()) { qWarning() << "No textures array in response"; return false; } Skin skinOut; // fill in default skin info ourselves, as this endpoint doesn't provide it bool steve = isDefaultModelSteve(output.id); skinOut.variant = steve ? "classic" : "slim"; skinOut.url = steve ? SKIN_URL_STEVE : SKIN_URL_ALEX; // sadly we can't figure this out, but I don't think it really matters... skinOut.id = "00000000-0000-0000-0000-000000000000"; Cape capeOut; auto tObj = textures.toObject(); for (auto idx = tObj.constBegin(); idx != tObj.constEnd(); ++idx) { if (idx->isObject()) { if (idx.key() == "SKIN") { auto skin = idx->toObject(); if (!getString(skin.value("url"), skinOut.url)) { qWarning() << "Skin url is not a string"; return false; } auto maybeMeta = skin.find("metadata"); if (maybeMeta != skin.end() && maybeMeta->isObject()) { auto meta = maybeMeta->toObject(); // might not be present getString(meta.value("model"), skinOut.variant); } } else if (idx.key() == "CAPE") { auto cape = idx->toObject(); if (!getString(cape.value("url"), capeOut.url)) { qWarning() << "Cape url is not a string"; return false; } // we don't know the cape ID as it is not returned from the session server // so just fake it - changing capes is probably locked anyway :( capeOut.alias = "cape"; } } } output.skin = skinOut; if (capeOut.alias == "cape") { output.capes = QMap({{capeOut.alias, capeOut}}); output.currentCape = capeOut.alias; } output.validity = Katabasis::Validity::Certain; return true; } bool parseMinecraftEntitlements(QByteArray & data, MinecraftEntitlement &output) { qDebug() << "Parsing Minecraft entitlements..."; #ifndef NDEBUG qDebug() << data; #endif QJsonParseError jsonError; QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); if(jsonError.error) { qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); return false; } auto obj = doc.object(); output.canPlayMinecraft = false; output.ownsMinecraft = false; auto itemsArray = obj.value("items").toArray(); for(auto item: itemsArray) { auto itemObj = item.toObject(); QString name; if(!getString(itemObj.value("name"), name)) { continue; } if(name == "game_minecraft") { output.canPlayMinecraft = true; } if(name == "product_minecraft") { output.ownsMinecraft = true; } } output.validity = Katabasis::Validity::Certain; return true; } bool parseRolloutResponse(QByteArray & data, bool& result) { qDebug() << "Parsing Rollout response..."; #ifndef NDEBUG qDebug() << data; #endif QJsonParseError jsonError; QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); if(jsonError.error) { qWarning() << "Failed to parse response from https://api.minecraftservices.com/rollout/v1/msamigration as JSON: " << jsonError.errorString(); return false; } auto obj = doc.object(); QString feature; if(!getString(obj.value("feature"), feature)) { qWarning() << "Rollout feature is not a string"; return false; } if(feature != "msamigration") { qWarning() << "Rollout feature is not what we expected (msamigration), but is instead \"" << feature << "\""; return false; } if(!getBool(obj.value("rollout"), result)) { qWarning() << "Rollout feature is not a string"; return false; } return true; } bool parseMojangResponse(QByteArray & data, Katabasis::Token &output) { QJsonParseError jsonError; qDebug() << "Parsing Mojang response..."; #ifndef NDEBUG qDebug() << data; #endif QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); if(jsonError.error) { qWarning() << "Failed to parse response from api.minecraftservices.com/launcher/login as JSON: " << jsonError.errorString(); return false; } auto obj = doc.object(); double expires_in = 0; if(!getNumber(obj.value("expires_in"), expires_in)) { qWarning() << "expires_in is not a valid number"; return false; } auto currentTime = QDateTime::currentDateTimeUtc(); output.issueInstant = currentTime; output.notAfter = currentTime.addSecs(expires_in); QString username; if(!getString(obj.value("username"), username)) { qWarning() << "username is not valid"; return false; } // TODO: it's a JWT... validate it? if(!getString(obj.value("access_token"), output.token)) { qWarning() << "access_token is not valid"; return false; } output.validity = Katabasis::Validity::Certain; qDebug() << "Mojang response is valid."; return true; } }