diff --git a/AltServer/AnisetteDataManager.cpp b/AltServer/AnisetteDataManager.cpp index 32add9a..c260646 100755 --- a/AltServer/AnisetteDataManager.cpp +++ b/AltServer/AnisetteDataManager.cpp @@ -10,6 +10,7 @@ #include #include "AnisetteData.h" +#include "ExternalAnisetteData.h" #include "AltServerApp.h" //#define SPOOF_MAC 1 @@ -362,6 +363,12 @@ bool AnisetteDataManager::LoadDependencies() std::shared_ptr AnisetteDataManager::FetchAnisetteData() { + const auto externalServer = _wgetenv(L"ALTSERVER_ANISETTE_SERVER"); + if (externalServer != nullptr && *externalServer != L'\0') + { + return ExternalAnisette::Fetch(externalServer); + } + if (!this->loadedDependencies) { this->LoadDependencies(); @@ -551,6 +558,9 @@ bool AnisetteDataManager::ReprovisionDevice(std::function provisionC bool AnisetteDataManager::ResetProvisioning() { + const auto externalServer = _wgetenv(L"ALTSERVER_ANISETTE_SERVER"); + if (externalServer != nullptr && *externalServer != L'\0') return false; + std::string adiDirectoryPath = "C:\\ProgramData\\Apple Computer\\iTunes\\adi"; // Remove existing AltServer .pb files so we can create new ones next time we provision this device. diff --git a/AltSign/AltSign.vcxproj b/AltSign/AltSign.vcxproj index 7d51545..e2d4dab 100755 --- a/AltSign/AltSign.vcxproj +++ b/AltSign/AltSign.vcxproj @@ -21,6 +21,7 @@ + @@ -44,6 +45,7 @@ + diff --git a/AltSign/AltSign.vcxproj.filters b/AltSign/AltSign.vcxproj.filters index 96ef0be..b710a64 100755 --- a/AltSign/AltSign.vcxproj.filters +++ b/AltSign/AltSign.vcxproj.filters @@ -18,6 +18,9 @@ Source Files + + Source Files + Source Files @@ -137,6 +140,9 @@ Header Files + + Header Files + Header Files diff --git a/AltSign/AnisetteData.cpp b/AltSign/AnisetteData.cpp index 3e9f02e..1e8af3a 100755 --- a/AltSign/AnisetteData.cpp +++ b/AltSign/AnisetteData.cpp @@ -8,6 +8,7 @@ #include "AnisetteData.h" #include +#include extern std::string StringFromWideString(std::wstring wideString); extern std::wstring WideStringFromString(std::string string); @@ -133,6 +134,28 @@ std::string AnisetteData::timeZone() const return _timeZone; } +std::optional AnisetteData::userAgent() const { return _userAgent; } +void AnisetteData::setUserAgent(std::string value) { _userAgent = std::move(value); } +void AnisetteData::setRefreshHandler(std::function()> handler) +{ + _refreshHandler = std::move(handler); +} + +void AnisetteData::refresh() +{ + if (!_refreshHandler) return; + auto fresh = _refreshHandler(); + if (!fresh || fresh->machineID() != _machineID || fresh->localUserID() != _localUserID || + fresh->deviceUniqueIdentifier() != _deviceUniqueIdentifier || fresh->deviceSerialNumber() != _deviceSerialNumber || + fresh->deviceDescription() != _deviceDescription || fresh->userAgent() != _userAgent) + throw std::runtime_error("External anisette identity changed during sign-in. Start a new sign-in session."); + _oneTimePassword = fresh->oneTimePassword(); + _routingInfo = fresh->routingInfo(); + _date = fresh->date(); + _locale = fresh->locale(); + _timeZone = fresh->timeZone(); +} + web::json::value AnisetteData::json() const { time_t time; diff --git a/AltSign/AnisetteData.h b/AltSign/AnisetteData.h index 44b3833..481d4ea 100755 --- a/AltSign/AnisetteData.h +++ b/AltSign/AnisetteData.h @@ -15,6 +15,8 @@ #include #include +#include +#include #include @@ -47,6 +49,10 @@ class AnisetteData TIMEVAL date() const; std::string locale() const; std::string timeZone() const; + std::optional userAgent() const; + void setUserAgent(std::string userAgent); + void setRefreshHandler(std::function()> handler); + void refresh(); web::json::value json() const; @@ -63,6 +69,8 @@ class AnisetteData TIMEVAL _date; std::string _locale; std::string _timeZone; + std::optional _userAgent; + std::function()> _refreshHandler; }; #pragma GCC visibility pop diff --git a/AltSign/AppleAPI+Authentication.cpp b/AltSign/AppleAPI+Authentication.cpp index 377edcc..a624588 100755 --- a/AltSign/AppleAPI+Authentication.cpp +++ b/AltSign/AppleAPI+Authentication.cpp @@ -565,16 +565,26 @@ pplx::task, std::shared_ptr> char* rawAuthType = nullptr; plist_get_string_val(authTypeNode, &rawAuthType); - authType = rawAuthType; + if (rawAuthType != nullptr) + { + authType = rawAuthType; + free(rawAuthType); + } } - if (authType == "trustedDeviceSecondaryAuth") + uint64_t authenticationStatus = 0; + auto authenticationStatusNode = plist_dict_get_item(statusDictionary, "hsc"); + if (authenticationStatusNode && plist_get_node_type(authenticationStatusNode) == PLIST_UINT) + plist_get_uint_val(authenticationStatusNode, &authenticationStatus); + bool codeAlreadySent = (!authType.has_value() || authType->empty()) && authenticationStatus == 409; + + if (authType == "trustedDeviceSecondaryAuth" || codeAlreadySent) { odslog("Requires trusted device two factor..."); if (verificationHandler.has_value()) { - return this->RequestTrustedDeviceTwoFactorCode(adsid, idmsToken, anisetteData, *verificationHandler) + return this->RequestTrustedDeviceTwoFactorCode(adsid, idmsToken, anisetteData, *verificationHandler, !codeAlreadySent) .then([=](bool success) { return this->Authenticate(unsanitizedAppleID, password, anisetteData, std::nullopt); }); @@ -756,27 +766,65 @@ pplx::task AppleAPI::FetchAuthToken(std::map }); } +namespace +{ + void CheckAuthenticationHTTPStatus(const http_response& response) + { + if (response.status_code() < 200 || response.status_code() >= 300) + throw LocalizedAPIError((int)APIErrorCode::InvalidResponse, + "Apple authentication returned HTTP " + std::to_string(response.status_code()) + "."); + } + + void RefreshClientParameters(plist_t dictionary, const std::shared_ptr& data) + { + if (!dictionary || plist_get_node_type(dictionary) != PLIST_DICT) return; + auto seconds = (time_t)data->date().tv_sec; + struct tm utc; + gmtime_s(&utc, &seconds); + char timestamp[32]; + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &utc); + plist_dict_set_item(dictionary, "X-Apple-I-Client-Time", plist_new_string(timestamp)); + plist_dict_set_item(dictionary, "X-Apple-I-MD", plist_new_string(data->oneTimePassword().c_str())); + plist_dict_set_item(dictionary, "X-Apple-I-MD-M", plist_new_string(data->machineID().c_str())); + plist_dict_set_item(dictionary, "X-Apple-I-MD-LU", plist_new_string(data->localUserID().c_str())); + plist_dict_set_item(dictionary, "X-Apple-I-MD-RINFO", plist_new_uint(data->routingInfo())); + plist_dict_set_item(dictionary, "X-Mme-Device-Id", plist_new_string(data->deviceUniqueIdentifier().c_str())); + plist_dict_set_item(dictionary, "X-Apple-I-SRL-NO", plist_new_string(data->deviceSerialNumber().c_str())); + plist_dict_set_item(dictionary, "X-Apple-Locale", plist_new_string(data->locale().c_str())); + plist_dict_set_item(dictionary, "X-Apple-I-TimeZone", plist_new_string(data->timeZone().c_str())); + plist_dict_set_item(dictionary, "loc", plist_new_string(data->locale().c_str())); + } +} + +web::http::client::http_client AppleAPI::authenticationClient(std::shared_ptr anisetteData) +{ + if (!anisetteData->userAgent().has_value()) return this->gsaClient(); + http_client_config config; + config.set_timeout(std::chrono::seconds(30)); + return http_client(U("https://gsa.apple.com"), config); +} + pplx::task AppleAPI::RequestTrustedDeviceTwoFactorCode( std::string dsid, std::string idmsToken, std::shared_ptr anisetteData, - const std::function >(void)>& verificationHandler) + const std::function >(void)>& verificationHandler, + bool requestCode) { std::string requestURL = "/auth/verify/trusteddevice"; std::string verifyURL = "/grandslam/GsService2/validate"; - auto request = this->MakeTwoFactorCodeRequest(requestURL, dsid, idmsToken, anisetteData); - - auto task = this->gsaClient().request(request) - .then([=](http_response response) - { - return response.content_ready(); - }) - .then([=](http_response response) - { - odslog("Received 2FA response status code: " << response.status_code()); - return response.extract_vector(); - }) + pplx::task> delivery = pplx::task_from_result(std::vector{}); + if (requestCode) + { + auto request = this->MakeTwoFactorCodeRequest(requestURL, dsid, idmsToken, anisetteData); + delivery = this->authenticationClient(anisetteData).request(request) + .then([](http_response response) { CheckAuthenticationHTTPStatus(response); return response.content_ready(); }) + .then([](http_response response) { return response.extract_vector(); }); + } + // hsc=409 with no au means Apple has already pushed the code. Do not + // request a replacement that would invalidate the code on the user's phone. + auto task = delivery .then([=](std::vector decompressedData) { return verificationHandler(); @@ -791,11 +839,11 @@ pplx::task AppleAPI::RequestTrustedDeviceTwoFactorCode( auto request = this->MakeTwoFactorCodeRequest(verifyURL, dsid, idmsToken, anisetteData); request.headers().add(L"security-code", WideStringFromString(*verificationCode)); - return this->gsaClient().request(request); + return this->authenticationClient(anisetteData).request(request); }) .then([=](http_response response) { - return response.content_ready(); + CheckAuthenticationHTTPStatus(response); return response.content_ready(); }) .then([=](http_response response) { @@ -890,10 +938,10 @@ pplx::task AppleAPI::RequestSMSTwoFactorCode( free(bodyXML); plist_free(bodyPlist); - auto task = this->gsaClient().request(request) + auto task = this->authenticationClient(anisetteData).request(request) .then([=](http_response response) { - return response.content_ready(); + CheckAuthenticationHTTPStatus(response); return response.content_ready(); }) .then([=](http_response response) { @@ -935,11 +983,11 @@ pplx::task AppleAPI::RequestSMSTwoFactorCode( free(bodyXML); plist_free(bodyPlist); - return this->gsaClient().request(request); + return this->authenticationClient(anisetteData).request(request); }) .then([=](http_response response) { - return response.content_ready(); + CheckAuthenticationHTTPStatus(response); return response.content_ready(); }) .then([=](http_response response) { @@ -988,6 +1036,13 @@ pplx::task> AppleAPI::FetchAccount(std::shared_ptr AppleAPI::SendAuthenticationRequest(std::map requestParameters, std::shared_ptr anisetteData) { + anisetteData->refresh(); + if (anisetteData->userAgent().has_value()) + { + auto cpd = requestParameters.find("cpd"); + if (cpd != requestParameters.end()) RefreshClientParameters(cpd->second, anisetteData); + } + auto header = plist_new_dict(); plist_dict_set_item(header, "Version", plist_new_string("1.0.1")); @@ -1016,7 +1071,7 @@ pplx::task AppleAPI::SendAuthenticationRequest(std::mapdeviceDescription())}, {L"Accept", L"*/*"}, - {L"User-Agent", L"akd/1.0 CFNetwork/978.0.7 Darwin/18.7.0"} + {L"User-Agent", WideStringFromString(anisetteData->userAgent().value_or("akd/1.0 CFNetwork/978.0.7 Darwin/18.7.0"))} }; uri_builder builder(U("/grandslam/GsService2")); @@ -1035,10 +1090,10 @@ pplx::task AppleAPI::SendAuthenticationRequest(std::mapgsaClient().request(request) + auto task = this->authenticationClient(anisetteData).request(request) .then([=](http_response response) { - return response.content_ready(); + CheckAuthenticationHTTPStatus(response); return response.content_ready(); }) .then([=](http_response response) { @@ -1153,6 +1208,8 @@ web::http::http_request AppleAPI::MakeTwoFactorCodeRequest( std::string idmsToken, std::shared_ptr anisetteData) { + anisetteData->refresh(); + auto encodedURI = web::uri::encode_uri(WideStringFromString(url)); uri_builder builder(encodedURI); @@ -1168,15 +1225,23 @@ web::http::http_request AppleAPI::MakeTwoFactorCodeRequest( char dateString[64]; time = anisetteData->date().tv_sec; - tm = localtime(&time); - - strftime(dateString, sizeof dateString, "%FT%T%z", tm); + struct tm providerTime; + if (anisetteData->userAgent().has_value()) + { + gmtime_s(&providerTime, &time); + strftime(dateString, sizeof dateString, "%Y-%m-%dT%H:%M:%SZ", &providerTime); + } + else + { + tm = localtime(&time); + strftime(dateString, sizeof dateString, "%FT%T%z", tm); + } std::map headers = { {L"Accept", L"application/x-buddyml"}, {L"Accept-Language", L"en-us"}, {L"Content-Type", L"application/x-plist"}, - {L"User-Agent", L"Xcode"}, + {L"User-Agent", WideStringFromString(anisetteData->userAgent().value_or("Xcode"))}, {L"X-Apple-App-Info", L"com.apple.gs.xcode.auth"}, {L"X-Xcode-Version", L"11.2 (11B41)"}, diff --git a/AltSign/AppleAPI.hpp b/AltSign/AppleAPI.hpp index 1412ad5..01248d2 100755 --- a/AltSign/AppleAPI.hpp +++ b/AltSign/AppleAPI.hpp @@ -75,6 +75,7 @@ class AppleAPI web::http::client::http_client _gsaClient; web::http::client::http_client gsaClient(); + web::http::client::http_client authenticationClient(std::shared_ptr anisetteData); web::http::client::http_client _client; web::http::client::http_client client(); @@ -105,7 +106,8 @@ class AppleAPI std::string dsid, std::string idmsToken, std::shared_ptr anisetteData, - const std::function >(void)>& verificationHandler); + const std::function >(void)>& verificationHandler, + bool requestCode = true); pplx::task RequestSMSTwoFactorCode( std::string dsid, diff --git a/AltSign/ExternalAnisetteData.cpp b/AltSign/ExternalAnisetteData.cpp new file mode 100644 index 0000000..172c26b --- /dev/null +++ b/AltSign/ExternalAnisetteData.cpp @@ -0,0 +1,143 @@ +#include "ExternalAnisetteData.h" +#include "Error.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#pragma comment(lib, "winhttp.lib") + +namespace +{ + constexpr size_t MaximumResponseSize = 16 * 1024; + + std::string RequiredString(const web::json::value& object, const utility::string_t& key) + { + if (!object.is_object() || !object.has_string_field(key)) + throw std::invalid_argument("Missing external anisette field: " + utility::conversions::to_utf8string(key)); + auto value = utility::conversions::to_utf8string(object.at(key).as_string()); + if (value.empty() || value.size() > 2048 || + std::any_of(value.begin(), value.end(), [](unsigned char c) { return c < 32 || c == 127; })) + throw std::invalid_argument("Invalid external anisette field: " + utility::conversions::to_utf8string(key)); + return value; + } + + web::json::value RequestJSON(const utility::string_t& url) + { + web::http::client::http_client_config config; + config.set_timeout(std::chrono::seconds(15)); + config.set_nativehandle_options([](web::http::client::native_handle handle) { + DWORD disabled = WINHTTP_DISABLE_REDIRECTS; + if (!WinHttpSetOption(handle, WINHTTP_OPTION_DISABLE_FEATURE, &disabled, sizeof(disabled))) + throw std::runtime_error("Could not disable external-provider redirects"); + }); + web::http::client::http_client client(url, config); + web::http::http_request request(web::http::methods::GET); + request.headers().add(U("Accept"), U("application/json")); + auto response = client.request(request).get(); + if (response.status_code() != web::http::status_codes::OK) + throw LocalizedAPIError((int)APIErrorCode::InvalidAnisetteData, + "External anisette provider returned HTTP " + std::to_string(response.status_code()) + "."); + if (response.headers().has(web::http::header_names::content_length) && + response.headers().content_length() > MaximumResponseSize) + throw std::invalid_argument("External anisette response is too large"); + std::vector bytes; + auto stream = response.body().streambuf(); + uint8_t chunk[1024]; + for (;;) + { + auto count = stream.getn(chunk, sizeof(chunk)).get(); + if (count == 0) break; + if (count > MaximumResponseSize - bytes.size()) + throw std::invalid_argument("External anisette response is too large"); + bytes.insert(bytes.end(), chunk, chunk + count); + } + try + { + auto json = web::json::value::parse(utility::conversions::to_string_t(std::string(bytes.begin(), bytes.end()))); + if (!json.is_object()) throw std::invalid_argument("Not an object"); + return json; + } + catch (...) + { + // Never echo malformed bodies: they can contain authentication data. + throw std::invalid_argument("External anisette response must be a JSON object"); + } + } +} + +utility::string_t ExternalAnisette::NormalizeServerURL(const utility::string_t& url) +{ + web::uri parsed; + try { parsed = web::uri(url); } + catch (...) { throw std::invalid_argument("Invalid external anisette server URL"); } + auto host = parsed.host(); + std::transform(host.begin(), host.end(), host.begin(), [](wchar_t c) { return (wchar_t)towlower(c); }); + bool loopback = host == U("localhost") || host == U("127.0.0.1") || host == U("[::1]") || host == U("::1"); + if (host.empty() || (parsed.scheme() != U("https") && !(parsed.scheme() == U("http") && loopback)) || + !parsed.user_info().empty() || !parsed.query().empty() || !parsed.fragment().empty()) + throw std::invalid_argument("External anisette URL requires HTTPS (or HTTP on loopback), with no credentials, query, or fragment"); + auto normalized = parsed.to_string(); + if (normalized.back() != U('/')) normalized += U('/'); + return normalized; +} + +std::shared_ptr ExternalAnisette::Parse(const web::json::value& headers, + const web::json::value& clientInfo, int64_t now) +{ + auto description = RequiredString(headers, U("X-MMe-Client-Info")); + auto providerDescription = RequiredString(clientInfo, U("client_info")); + auto userAgent = RequiredString(clientInfo, U("user_agent")); + if (description != providerDescription) + throw std::invalid_argument("Anisette headers and provider metadata have different client identities"); + auto routingString = RequiredString(headers, U("X-Apple-I-MD-RINFO")); + if (!std::all_of(routingString.begin(), routingString.end(), [](unsigned char c) { return std::isdigit(c) != 0; })) + throw std::invalid_argument("Anisette routing info must be an unsigned decimal integer"); + unsigned long long routing; + try { routing = std::stoull(routingString); } + catch (...) { throw std::invalid_argument("Anisette routing info is outside the supported range"); } + auto timeString = RequiredString(headers, U("X-Apple-I-Client-Time")); + int64_t seconds; + try + { + auto date = utility::datetime::from_string(utility::conversions::to_string_t(timeString), utility::datetime::ISO_8601); + seconds = static_cast(date.to_interval() / 10000000) - 11644473600LL; + } + catch (...) { throw std::invalid_argument("Invalid external anisette timestamp"); } + if (seconds < now - 120 || seconds > now + 30 || seconds < 0 || seconds > (std::numeric_limits::max)()) + throw std::invalid_argument("External anisette timestamp is stale or outside the supported range"); + timeval date = { static_cast(seconds), 0 }; + auto data = std::make_shared( + RequiredString(headers, U("X-Apple-I-MD-M")), RequiredString(headers, U("X-Apple-I-MD")), + RequiredString(headers, U("X-Apple-I-MD-LU")), routing, + RequiredString(headers, U("X-Mme-Device-Id")), RequiredString(headers, U("X-Apple-I-SRL-NO")), + description, date, RequiredString(headers, U("X-Apple-Locale")), RequiredString(headers, U("X-Apple-I-TimeZone"))); + data->setUserAgent(userAgent); + return data; +} + +std::shared_ptr ExternalAnisette::Fetch(const utility::string_t& url) +{ + try + { + auto base = NormalizeServerURL(url); + auto metadata = RequestJSON(base + U("v3/client_info")); + auto headers = RequestJSON(base); + auto data = Parse(headers, metadata, static_cast(std::time(nullptr))); + data->setRefreshHandler([base] { return Fetch(base); }); + return data; + } + catch (const LocalizedAPIError&) { throw; } + catch (const std::invalid_argument& error) + { + throw LocalizedAPIError((int)APIErrorCode::InvalidAnisetteData, error.what()); + } + catch (...) + { + throw LocalizedAPIError((int)APIErrorCode::InvalidAnisetteData, + "Could not fetch external anisette data. Check the configured provider and try again."); + } +} diff --git a/AltSign/ExternalAnisetteData.h b/AltSign/ExternalAnisetteData.h new file mode 100644 index 0000000..074f37f --- /dev/null +++ b/AltSign/ExternalAnisetteData.h @@ -0,0 +1,12 @@ +#pragma once +#include "AnisetteData.h" + +namespace ExternalAnisette +{ + // Header-provider API: GET / and GET /v3/client_info. + // No Apple account, password, or verification code is sent to this provider. + utility::string_t NormalizeServerURL(const utility::string_t& url); + std::shared_ptr Parse(const web::json::value& headers, + const web::json::value& clientInfo, int64_t now); + std::shared_ptr Fetch(const utility::string_t& url); +} diff --git a/Tests/.gitignore b/Tests/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/Tests/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/Tests/AuthenticationCompile.vcxproj b/Tests/AuthenticationCompile.vcxproj new file mode 100644 index 0000000..a968a7b --- /dev/null +++ b/Tests/AuthenticationCompile.vcxproj @@ -0,0 +1,12 @@ + + + Releasex64ReleaseWin32 + {82451337-C9BC-4901-A5CD-C3EA5EC0401B}10.0 + + StaticLibraryv142falseUnicode + + $(MSBuildThisFileDirectory)build\$(Platform)\$(OutDir)authentication-obj\ + stdcpp17MultiThreadedDLL$(MSBuildThisFileDirectory)..\AltSign;$(MSBuildThisFileDirectory)..\AltSign\Dependencies;$(MSBuildThisFileDirectory)..\libplist\include;$(CppRestSdkRoot)\include;%(AdditionalIncludeDirectories)_CRT_SECURE_NO_WARNINGS;NOMINMAX;CORECRYPTO_DONOT_USE_TRANSPARENT_UNION;%(PreprocessorDefinitions) + + + diff --git a/Tests/ExternalAnisetteTests.cpp b/Tests/ExternalAnisetteTests.cpp new file mode 100644 index 0000000..170e83d --- /dev/null +++ b/Tests/ExternalAnisetteTests.cpp @@ -0,0 +1,130 @@ +#include "ExternalAnisetteData.h" +#include +#include +#include + +std::string StringFromWideString(std::wstring value) { return utility::conversions::to_utf8string(value); } +std::wstring WideStringFromString(std::string value) { return utility::conversions::to_string_t(value); } + +namespace +{ + using web::json::value; + constexpr int64_t Now = 1700000000; + int tests = 0; + void Check(bool condition) { if (!condition) throw std::runtime_error("Test assertion failed"); } + void Test(const char* name, std::function body) + { + body(); ++tests; std::cout << "PASS " << name << '\n'; + } + void Reject(std::function body) + { + bool rejected = false; + try { body(); } catch (...) { rejected = true; } + Check(rejected); + } + value Headers() + { + return value::parse(U(R"({ + "X-Apple-I-MD-M":"fixture-machine", "X-Apple-I-MD":"old-otp", + "X-Apple-I-MD-LU":"fixture-local-user", "X-Apple-I-MD-RINFO":"17106176", + "X-Mme-Device-Id":"fixture-device", "X-Apple-I-SRL-NO":"0", + "X-MMe-Client-Info":"fixture-client", "X-Apple-I-Client-Time":"2023-11-14T22:13:20Z", + "X-Apple-Locale":"en_US", "X-Apple-I-TimeZone":"UTC" + })")); + } + value Metadata() { return value::parse(U(R"({"client_info":"fixture-client","user_agent":"fixture-agent"})")); } + std::shared_ptr Parse(value h = Headers(), value m = Metadata()) + { + return ExternalAnisette::Parse(h, m, Now); + } +} + +int main(int argc, char** argv) +{ + try + { + if (argc >= 3) + { + auto request = [&] { + auto data = ExternalAnisette::Fetch(utility::conversions::to_string_t(argv[2])); + if (std::string(argv[1]) != "--probe-http") + Check(data->userAgent() == std::make_optional(std::string("fixture-agent"))); + data->refresh(); + }; + if (std::string(argv[1]) == "--reject-http") Reject(request); + else request(); + std::cout << "Provider HTTP checks passed\n"; + return 0; + } + Test("parse matching header and client-info responses", [] { + auto data = Parse(); + Check(data->date().tv_sec == Now && data->oneTimePassword() == "old-otp"); + Check(data->userAgent() == std::make_optional(std::string("fixture-agent"))); + }); + Test("routing info preserves all 64 bits", [] { + auto h = Headers(); h[U("X-Apple-I-MD-RINFO")] = value::string(U("18446744073709551615")); + Check(Parse(h)->routingInfo() == 18446744073709551615ULL); + }); + Test("reject invalid routing info", [] { + for (auto invalid : {U("-1"), U("+1"), U("1x"), U("18446744073709551616")}) { + auto h = Headers(); h[U("X-Apple-I-MD-RINFO")] = value::string(invalid); Reject([&] { Parse(h); }); + } + }); + Test("reject missing or empty OTP", [] { + auto h = Headers(); h.erase(U("X-Apple-I-MD")); Reject([&] { Parse(h); }); + h[U("X-Apple-I-MD")] = value::string(U("")); Reject([&] { Parse(h); }); + }); + Test("reject invalid field types", [] { + auto h = Headers(); h[U("X-Apple-I-MD")] = value::number(12); Reject([&] { Parse(h); }); + }); + Test("reject header injection", [] { + auto m = Metadata(); m[U("user_agent")] = value::string(U("agent\r\nInjected: value")); Reject([&] { Parse(Headers(), m); }); + }); + Test("reject metadata identity mismatch", [] { + auto m = Metadata(); m[U("client_info")] = value::string(U("another-client")); Reject([&] { Parse(Headers(), m); }); + }); + Test("reject stale timestamp", [] { Reject([] { ExternalAnisette::Parse(Headers(), Metadata(), Now + 121); }); }); + Test("reject timestamp too far in future", [] { Reject([] { ExternalAnisette::Parse(Headers(), Metadata(), Now - 31); }); }); + Test("reject malformed timestamp", [] { + auto h = Headers(); h[U("X-Apple-I-Client-Time")] = value::string(U("invalid")); Reject([&] { Parse(h); }); + }); + Test("normalize HTTPS base URL", [] { + Check(ExternalAnisette::NormalizeServerURL(U("https://example.test/provider")) == U("https://example.test/provider/")); + }); + Test("allow loopback HTTP", [] { + ExternalAnisette::NormalizeServerURL(U("http://127.0.0.1:9000")); + ExternalAnisette::NormalizeServerURL(U("http://localhost:9000")); + ExternalAnisette::NormalizeServerURL(U("http://[::1]:9000")); + }); + Test("reject insecure remote and non-HTTP URLs", [] { + for (auto url : {U("http://example.test"), U("file:///tmp/data"), U("https:///missing-host")}) + Reject([&] { ExternalAnisette::NormalizeServerURL(url); }); + }); + Test("reject URL credentials, query and fragment", [] { + for (auto url : {U("https://user:password@example.test"), U("https://example.test/?secret=value"), U("https://example.test/#fragment")}) + Reject([&] { ExternalAnisette::NormalizeServerURL(url); }); + }); + Test("refresh updates OTP on the same identity", [] { + auto data = Parse(); + data->setRefreshHandler([] { auto h = Headers(); h[U("X-Apple-I-MD")] = value::string(U("fresh-otp")); return Parse(h); }); + data->refresh(); Check(data->oneTimePassword() == "fresh-otp"); + }); + Test("identity rotation fails before changing OTP", [] { + for (auto key : {U("X-Apple-I-MD-M"), U("X-Apple-I-MD-LU"), U("X-Mme-Device-Id"), U("X-Apple-I-SRL-NO")}) { + auto data = Parse(); + data->setRefreshHandler([key] { auto h = Headers(); h[key] = value::string(U("rotated")); h[U("X-Apple-I-MD")] = value::string(U("fresh-otp")); return Parse(h); }); + Reject([&] { data->refresh(); }); Check(data->oneTimePassword() == "old-otp"); + } + }); + Test("user-agent rotation fails closed", [] { + auto data = Parse(); data->setRefreshHandler([] { auto m = Metadata(); m[U("user_agent")] = value::string(U("another-agent")); return Parse(Headers(), m); }); + Reject([&] { data->refresh(); }); + }); + Test("local data keeps legacy refresh behavior", [] { + AnisetteData local; local.refresh(); Check(!local.userAgent().has_value()); + }); + std::cout << tests << " tests passed\n"; + return 0; + } + catch (const std::exception& error) { std::cerr << "Test failed: " << error.what() << '\n'; return 1; } +} diff --git a/Tests/ExternalAnisetteTests.vcxproj b/Tests/ExternalAnisetteTests.vcxproj new file mode 100644 index 0000000..b6be66a --- /dev/null +++ b/Tests/ExternalAnisetteTests.vcxproj @@ -0,0 +1,22 @@ + + + + Releasex64 + ReleaseWin32 + + {C2DB17C0-9DA1-4CDE-AC17-45498BE31528}10.0 + + Applicationv142falseUnicode + + + $(MSBuildThisFileDirectory)build\$(Platform)\$(OutDir)obj\ + x86x64 + + + stdcpp17MultiThreadedDLL$(MSBuildThisFileDirectory)..\AltSign;$(CppRestSdkRoot)\include;%(AdditionalIncludeDirectories)_CRT_SECURE_NO_WARNINGS;NOMINMAX;%(PreprocessorDefinitions)Level3 + $(CppRestSdkRoot)\$(SdkArchitecture)\lib;%(AdditionalLibraryDirectories)cpprest142_2_10.lib;Ws2_32.lib;%(AdditionalDependencies) + + + + + diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 0000000..83d024b --- /dev/null +++ b/Tests/README.md @@ -0,0 +1,31 @@ +# External anisette native tests + +Requires MSVC v142, a Windows 10 SDK, Python 3, and the headers/import library/DLL +from the `cpprestsdk.v142` NuGet package, version **2.10.15**. Extract the package +outside the source tree. `CppRestSdkRoot` below is its `build/native` directory +(containing `include/`, `x86/` and `x64/`). This is an isolated test dependency; +it does not change AltServer's existing dependency setup. + +Run from a Visual Studio developer shell at the repository root: + +```powershell +msbuild Tests\ExternalAnisetteTests.vcxproj /p:Configuration=Release /p:Platform=x64 /p:CppRestSdkRoot=C:\deps\cpprestsdk\build\native +& Tests\build\x64\ExternalAnisetteTests.exe +python Tests\external_anisette_http_tests.py Tests\build\x64\ExternalAnisetteTests.exe +``` + +Repeat using `Platform=Win32` and `Tests/build/Win32/ExternalAnisetteTests.exe` for +the 32-bit Windows target. The 18 native cases and 9 loopback HTTP cases use only +synthetic data. The timeout case deliberately waits for the SDK's 15-second limit. +No Apple account, attached device, installed AltServer, or public provider is used. + +`AuthenticationCompile.vcxproj` additionally compiles the changed production +authentication translation unit against the repository's crypto/plist headers, +without linking an app or supplying Apple credentials: + +```powershell +msbuild Tests\AuthenticationCompile.vcxproj /p:Configuration=Release /p:Platform=x64 /p:CppRestSdkRoot=C:\deps\cpprestsdk\build\native +``` + +This compile check and the adapter tests do not replace the full application's +build, link, and end-to-end sign-in/2FA/device tests. diff --git a/Tests/external_anisette_http_tests.py b/Tests/external_anisette_http_tests.py new file mode 100644 index 0000000..4553fb1 --- /dev/null +++ b/Tests/external_anisette_http_tests.py @@ -0,0 +1,106 @@ +"""Exercise the production HTTP adapter using loopback-only synthetic fixtures.""" +import datetime +import http.server +import json +import subprocess +import sys +import threading + + +class Provider(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + requests_seen = [] + root_requests = {} + violations = [] + + def log_message(self, *_): + pass + + def do_GET(self): + self.requests_seen.append(self.path) + for name in ("Authorization", "Cookie", "security-code"): + if name in self.headers: + self.violations.append(name) + scenario = self.path.split("/")[1] + if scenario == "timeout": + threading.Event().wait(20) + self.close_connection = True + return + metadata = self.path.endswith("v3/client_info") + status = 200 + headers = {} + payload = {"client_info": "fixture-client", "user_agent": "fixture-agent"} + if not metadata: + count = self.root_requests.get(scenario, 0) + 1 + self.root_requests[scenario] = count + payload = { + "X-Apple-I-MD-M": "fixture-machine", "X-Apple-I-MD": f"fixture-otp-{count}", + "X-Apple-I-MD-LU": "fixture-local-user", "X-Apple-I-MD-RINFO": "17106176", + "X-Mme-Device-Id": "fixture-device", "X-Apple-I-SRL-NO": "0", + "X-MMe-Client-Info": "fixture-client", "X-Apple-Locale": "en_US", + "X-Apple-I-TimeZone": "UTC", + "X-Apple-I-Client-Time": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + if scenario == "rotate" and count > 1: + payload["X-Apple-I-MD-M"] = "different-machine" + if scenario == "mismatch": + payload["X-MMe-Client-Info"] = "different-client" + body = json.dumps(payload).encode() + if scenario == "bad-json": + body = b"PRIVATE_FIXTURE_BODY" + elif scenario == "http-error": + status, body = 503, b"PRIVATE_FIXTURE_BODY" + elif scenario == "redirect": + status = 302 + headers["Location"] = "/redirect-target/v3/client_info" + elif scenario in ("oversize", "chunked-oversize"): + body = b"x" * 20000 + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Connection", "close") + self.close_connection = True + for name, value in headers.items(): + self.send_header(name, value) + if scenario == "chunked-oversize": + self.send_header("Transfer-Encoding", "chunked") + else: + self.send_header("Content-Length", str(len(body))) + self.end_headers() + try: + if scenario == "chunked-oversize": + for offset in range(0, len(body), 1024): + chunk = body[offset:offset + 1024] + self.wfile.write(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n") + self.wfile.write(b"0\r\n\r\n") + else: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass + + +def main(): + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Provider) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + cases = ["ok", "rotate", "mismatch", "bad-json", "http-error", "redirect", "oversize", "chunked-oversize", "timeout"] + try: + for scenario in cases: + mode = "--fetch-http" if scenario == "ok" else "--reject-http" + url = f"http://127.0.0.1:{server.server_port}/{scenario}/" + result = subprocess.run([sys.argv[1], mode, url], capture_output=True, text=True, timeout=40) + assert result.returncode == 0, f"{scenario}: {result.stdout}\n{result.stderr}" + assert "PRIVATE_FIXTURE_BODY" not in result.stdout + result.stderr + assert any(path.startswith(f"/{scenario}/") for path in Provider.requests_seen) + print(f"PASS HTTP {scenario}") + assert not any("redirect-target" in path for path in Provider.requests_seen) + assert Provider.root_requests["ok"] == 2, "refresh must fetch new data" + assert Provider.root_requests["rotate"] == 2, "rotation must be detected on refresh" + assert not Provider.violations, "Provider received sensitive request headers" + print(f"{len(cases)} HTTP integration tests passed") + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/docs/external-anisette.md b/docs/external-anisette.md new file mode 100644 index 0000000..929e4cb --- /dev/null +++ b/docs/external-anisette.md @@ -0,0 +1,80 @@ +# External anisette provider (Windows) + +Set `ALTSERVER_ANISETTE_SERVER` in AltServer's environment before launching it to +use an external header provider instead of iTunes anisette. With the variable +unset or empty, the existing local implementation is used. + +For example, in PowerShell, substitute the URL of a provider you administer or +trust, then launch your **custom-built** AltServer from that same shell: + +```powershell +$env:ALTSERVER_ANISETTE_SERVER = 'https://anisette.example.test/' +& '.\AltServer.exe' +``` + +This source change does not update an installed AltServer executable. A normal +Windows installation still needs the existing device communication dependencies. +Remove the environment variable and restart AltServer to return to local anisette. + +## Provider contract + +The URL is a base URL with two credential-free GET endpoints: + +- `/v3/client_info` returns `client_info` and `user_agent` strings. +- `/` returns the header dictionary below, using string values. + +```json +{ + "X-Apple-I-MD-M": "", + "X-Apple-I-MD": "", + "X-Apple-I-MD-LU": "", + "X-Apple-I-MD-RINFO": "17106176", + "X-Mme-Device-Id": "", + "X-Apple-I-SRL-NO": "0", + "X-MMe-Client-Info": "", + "X-Apple-I-Client-Time": "", + "X-Apple-Locale": "en_US", + "X-Apple-I-TimeZone": "UTC" +} +``` + +This is the header-provider API exposed by some anisette servers. It is **not** +a new implementation of the per-client v3 provisioning/WebSocket protocol. A +server exposing only `/v3/get_headers` and `/v3/provisioning_session` is not +compatible with this adapter. The provider manages its own provisioning state. + +There is no default public provider. HTTPS and normal certificate validation are +required, except that HTTP is accepted for literal loopback/localhost providers. +Redirects are disabled; each request has a 15-second SDK timeout and a 16 KiB +response limit. Provider responses and authentication payloads are not added to +diagnostics. Apple credentials and verification codes go only to Apple's existing +authentication endpoints, never to this provider. + +## Sign-in behavior + +GrandSlam and verification requests use the provider's user agent together with +its client info. Before each sign-in/verification request, fresh data is fetched; +GrandSlam's `cpd` dictionary is updated too, including the app-token request after +verification. Provider-backed requests use fresh HTTP clients with certificate +validation enabled. + +The machine ID, local user ID, device ID, serial number, client info and user agent +are pinned for the sign-in. If the provider changes identity mid-session, sign-in +stops instead of combining one session's tokens with another identity. Start a new +sign-in after resolving the provider change. Public shared providers may impose +their own limits; a provider serving unstable identities is unsuitable here. + +The trusted-device flow also handles `hsc=409` with an absent/empty `au`: it prompts +for Apple's already-pushed code without requesting another code. Non-2xx HTTP +responses are reported by status instead of passing HTML to the plist parser. +No automatic credential or code-delivery retries are added. + +The existing AltStore TCP response format, device selection and USB installation +path are retained. Local ADI state is not reset for external-provider failures. + +## Validation boundary + +The isolated tests exercise native C++ parsing, refresh identity checks, actual +loopback HTTP requests, malformed/oversized responses, redirects and timeouts. +They do not sign into an Apple account or install apps. A full AltServer build and +an end-to-end Apple sign-in/2FA/install test are still required before release.