From 55e868030015a11e4a3b682d65299c1ced3a3643 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Date: Mon, 2 Feb 2026 13:49:13 +0530 Subject: [PATCH 1/4] feat: login using AAS Token --- gpapi/googleplay.py | 146 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/gpapi/googleplay.py b/gpapi/googleplay.py index b809b18..2a04a51 100644 --- a/gpapi/googleplay.py +++ b/gpapi/googleplay.py @@ -290,6 +290,152 @@ def login(self, email=None, password=None, gsfId=None, authSubToken=None, return else: raise LoginError('Either (email,pass) or (gsfId, authSubToken) is needed') + def login_with_aas_token(self, email, aas_token): + """Login using an AAS token from oauth-android-app. + + This method: + 1. Uses the AAS token to get an AC2DM token + 2. Performs device checkin to get a real GSF ID + 3. Gets the authSubToken for Play Store API access + 4. Uploads device configuration + + Args: + email (str): Google account email + aas_token (str): AAS token from oauth-android-app (format: aas_et/...) + """ + # Step 1: Get AC2DM token using AAS token + params = { + 'Email': email, + 'Token': aas_token, + 'service': 'ac2dm', + 'add_account': '1', + 'get_accountid': '1', + 'ACCESS_TOKEN': '1', + 'callerPkg': 'com.google.android.gms', + 'callerSig': '38918a453d07199354f8b19af05ec6562ced5788', + 'device_country': self.deviceBuilder.locale[0:2], + 'lang': self.deviceBuilder.locale, + 'sdk_version': self.deviceBuilder.device.get('build.version.sdk_int', '28'), + 'google_play_services_version': self.deviceBuilder.device.get('gsf.version', '19629032'), + } + + with requests.Session() as s: + s.mount('https://', AuthHTTPAdapter()) + s.headers = {'User-Agent': 'GoogleAuth/1.4'} + response = s.post(AUTH_URL, + data=params, + verify=self.ssl_verify, + proxies=self.proxies_config) + + data = response.text.split() + result = {} + for d in data: + if "=" not in d: + continue + k, v = d.split("=", 1) + result[k.strip().lower()] = v.strip() + + if "auth" not in result: + error = result.get("error", "Unknown error") + raise LoginError(f"Failed to get AC2DM token: {error}") + + ac2dm_token = result["auth"] + + # Step 2: Perform device checkin to get real GSF ID + self.gsfId = self.checkin(email, ac2dm_token) + + # Step 3: Get authSubToken using AAS token + self._get_auth_sub_token_from_aas(email, aas_token) + + # Step 4: Upload device configuration + self.uploadDeviceConfig() + + def _get_auth_sub_token_from_aas(self, email, aas_token): + """Get authSubToken using AAS token instead of password.""" + params = { + 'Email': email, + 'Token': aas_token, + 'service': 'androidmarket', + 'app': 'com.android.vending', + 'callerPkg': 'com.android.vending', + 'callerSig': '38918a453d07199354f8b19af05ec6562ced5788', + 'client_sig': '38918a453d07199354f8b19af05ec6562ced5788', + 'device_country': self.deviceBuilder.locale[0:2], + 'lang': self.deviceBuilder.locale, + 'sdk_version': self.deviceBuilder.device.get('build.version.sdk_int', '28'), + 'google_play_services_version': self.deviceBuilder.device.get('gsf.version', '19629032'), + } + + if self.gsfId is not None: + params['androidId'] = "{0:x}".format(self.gsfId) + + headers = self.deviceBuilder.getAuthHeaders(self.gsfId) + headers['app'] = 'com.android.vending' + + response = self.session.post(AUTH_URL, + data=params, + headers=headers, + verify=self.ssl_verify, + proxies=self.proxies_config) + + data = response.text.split() + result = {} + for d in data: + if "=" not in d: + continue + k, v = d.split("=", 1) + result[k.strip().lower()] = v.strip() + + if "auth" in result: + self.setAuthSubToken(result["auth"]) + elif "token" in result: + # Need second round token exchange + second_round = self._get_second_round_from_aas(result["token"], email, aas_token) + self.setAuthSubToken(second_round) + else: + error = result.get("error", "Unknown error") + raise LoginError(f"Failed to get authSubToken: {error}") + + def _get_second_round_from_aas(self, first_token, email, aas_token): + """Get second round token using first token and AAS token.""" + params = { + 'Token': first_token, + 'service': 'androidmarket', + 'app': 'com.android.vending', + 'check_email': '1', + 'token_request_options': 'CAA4AQ==', + 'system_partition': '1', + '_opt_is_called_from_account_manager': '1', + 'callerPkg': 'com.android.vending', + 'callerSig': '38918a453d07199354f8b19af05ec6562ced5788', + } + + if self.gsfId is not None: + params['androidId'] = "{0:x}".format(self.gsfId) + + headers = self.deviceBuilder.getAuthHeaders(self.gsfId) + headers['app'] = 'com.android.vending' + + response = self.session.post(AUTH_URL, + data=params, + headers=headers, + verify=self.ssl_verify, + proxies=self.proxies_config) + + data = response.text.split() + result = {} + for d in data: + if "=" not in d: + continue + k, v = d.split("=", 1) + result[k.strip().lower()] = v.strip() + + if "auth" in result: + return result["auth"] + else: + error = result.get("error", "Unknown error") + raise LoginError(f"Failed to get second round token: {error}") + def getAuthSubToken(self, email, passwd): requestParams = self.deviceBuilder.getLoginParams(email, passwd) requestParams['service'] = 'androidmarket' From 96beac639b9cff9a8e3ed473deb33193969d3326 Mon Sep 17 00:00:00 2001 From: Ashutosh Tripathi Date: Mon, 2 Feb 2026 14:46:52 +0530 Subject: [PATCH 2/4] Add: login_with_aas skip AC2DM check --- gpapi/googleplay.py | 70 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/gpapi/googleplay.py b/gpapi/googleplay.py index 2a04a51..ba62e2c 100644 --- a/gpapi/googleplay.py +++ b/gpapi/googleplay.py @@ -293,12 +293,15 @@ def login(self, email=None, password=None, gsfId=None, authSubToken=None, return def login_with_aas_token(self, email, aas_token): """Login using an AAS token from oauth-android-app. - This method: + This method attempts the full authentication flow: 1. Uses the AAS token to get an AC2DM token 2. Performs device checkin to get a real GSF ID 3. Gets the authSubToken for Play Store API access 4. Uploads device configuration + Note: This may fail with 'MissingDroidguard' error if Google requires + device attestation. In that case, use login_with_aas_token_simple() instead. + Args: email (str): Google account email aas_token (str): AAS token from oauth-android-app (format: aas_et/...) @@ -350,6 +353,71 @@ def login_with_aas_token(self, email, aas_token): # Step 4: Upload device configuration self.uploadDeviceConfig() + def login_with_aas_token_simple(self, email, aas_token): + """Login using an AAS token with simplified flow (bypasses AC2DM). + + This method bypasses the AC2DM token exchange which may require DroidGuard + attestation. It uses a direct approach: + 1. Performs device checkin with AAS token to associate account + 2. Gets the authSubToken directly using AAS token + 3. Uploads device configuration + 4. Gets table of contents (dfeCookie) + + Note: This simpler flow works when login_with_aas_token() fails with + 'MissingDroidguard' error, but may have reduced functionality. + + BUT this returns no versionCode or versionInfo + + Args: + email (str): Google account email + aas_token (str): AAS token from oauth-android-app (format: aas_et/...) + """ + # Step 1: Device checkin with account association using AAS token + self.gsfId = self._checkin_with_aas(email, aas_token) + + # Step 2: Get authSubToken directly using AAS token (skip AC2DM) + self._get_auth_sub_token_from_aas(email, aas_token) + + # Step 3: Upload device configuration + self.uploadDeviceConfig() + + # Step 4: Get ToC to obtain dfeCookie (needed for full API access) + self.toc() + + def _checkin_with_aas(self, email, aas_token): + """Perform device checkin and associate account using AAS token. + + This does two checkins: + 1. Initial checkin to get GSF ID and security token + 2. Second checkin with account cookie to register the account + """ + headers = self.getHeaders() + headers["Content-Type"] = CONTENT_TYPE_PROTO + + # First checkin - get GSF ID and security token + request = self.deviceBuilder.getAndroidCheckinRequest() + stringRequest = request.SerializeToString() + res = self.session.post(CHECKIN_URL, data=stringRequest, + headers=headers, verify=self.ssl_verify, + proxies=self.proxies_config) + response = googleplay_pb2.AndroidCheckinResponse() + response.ParseFromString(res.content) + self.deviceCheckinConsistencyToken = response.deviceCheckinConsistencyToken + + # Second checkin - associate account with device using AAS token + request.id = response.androidId + request.securityToken = response.securityToken + request.accountCookie.append("[" + email + "]") + request.accountCookie.append(aas_token) # Use AAS token as account cookie + stringRequest = request.SerializeToString() + self.session.post(CHECKIN_URL, + data=stringRequest, + headers=headers, + verify=self.ssl_verify, + proxies=self.proxies_config) + + return response.androidId + def _get_auth_sub_token_from_aas(self, email, aas_token): """Get authSubToken using AAS token instead of password.""" params = { From 9d94de1daf28dd5a587d3a254a5f1fad24ee056b Mon Sep 17 00:00:00 2001 From: Suresh Kumar Date: Mon, 2 Feb 2026 16:17:38 +0530 Subject: [PATCH 3/4] fix: AAS Token auth error --- gpapi/googleplay.py | 78 +++------------------------------------------ 1 file changed, 5 insertions(+), 73 deletions(-) diff --git a/gpapi/googleplay.py b/gpapi/googleplay.py index ba62e2c..9a4586d 100644 --- a/gpapi/googleplay.py +++ b/gpapi/googleplay.py @@ -293,97 +293,29 @@ def login(self, email=None, password=None, gsfId=None, authSubToken=None, return def login_with_aas_token(self, email, aas_token): """Login using an AAS token from oauth-android-app. - This method attempts the full authentication flow: - 1. Uses the AAS token to get an AC2DM token - 2. Performs device checkin to get a real GSF ID - 3. Gets the authSubToken for Play Store API access - 4. Uploads device configuration - - Note: This may fail with 'MissingDroidguard' error if Google requires - device attestation. In that case, use login_with_aas_token_simple() instead. - - Args: - email (str): Google account email - aas_token (str): AAS token from oauth-android-app (format: aas_et/...) - """ - # Step 1: Get AC2DM token using AAS token - params = { - 'Email': email, - 'Token': aas_token, - 'service': 'ac2dm', - 'add_account': '1', - 'get_accountid': '1', - 'ACCESS_TOKEN': '1', - 'callerPkg': 'com.google.android.gms', - 'callerSig': '38918a453d07199354f8b19af05ec6562ced5788', - 'device_country': self.deviceBuilder.locale[0:2], - 'lang': self.deviceBuilder.locale, - 'sdk_version': self.deviceBuilder.device.get('build.version.sdk_int', '28'), - 'google_play_services_version': self.deviceBuilder.device.get('gsf.version', '19629032'), - } - - with requests.Session() as s: - s.mount('https://', AuthHTTPAdapter()) - s.headers = {'User-Agent': 'GoogleAuth/1.4'} - response = s.post(AUTH_URL, - data=params, - verify=self.ssl_verify, - proxies=self.proxies_config) - - data = response.text.split() - result = {} - for d in data: - if "=" not in d: - continue - k, v = d.split("=", 1) - result[k.strip().lower()] = v.strip() - - if "auth" not in result: - error = result.get("error", "Unknown error") - raise LoginError(f"Failed to get AC2DM token: {error}") - - ac2dm_token = result["auth"] - - # Step 2: Perform device checkin to get real GSF ID - self.gsfId = self.checkin(email, ac2dm_token) - - # Step 3: Get authSubToken using AAS token - self._get_auth_sub_token_from_aas(email, aas_token) - - # Step 4: Upload device configuration - self.uploadDeviceConfig() - - def login_with_aas_token_simple(self, email, aas_token): - """Login using an AAS token with simplified flow (bypasses AC2DM). - - This method bypasses the AC2DM token exchange which may require DroidGuard - attestation. It uses a direct approach: + This method uses a simplified authentication flow that bypasses the + AC2DM token exchange which may require DroidGuard attestation: 1. Performs device checkin with AAS token to associate account 2. Gets the authSubToken directly using AAS token 3. Uploads device configuration 4. Gets table of contents (dfeCookie) - Note: This simpler flow works when login_with_aas_token() fails with - 'MissingDroidguard' error, but may have reduced functionality. - - BUT this returns no versionCode or versionInfo - Args: email (str): Google account email aas_token (str): AAS token from oauth-android-app (format: aas_et/...) """ # Step 1: Device checkin with account association using AAS token self.gsfId = self._checkin_with_aas(email, aas_token) - + # Step 2: Get authSubToken directly using AAS token (skip AC2DM) self._get_auth_sub_token_from_aas(email, aas_token) # Step 3: Upload device configuration self.uploadDeviceConfig() - + # Step 4: Get ToC to obtain dfeCookie (needed for full API access) self.toc() - + def _checkin_with_aas(self, email, aas_token): """Perform device checkin and associate account using AAS token. From a39e8d57cece656656a44d6a460c4384f3f7bea4 Mon Sep 17 00:00:00 2001 From: Ashutosh Tripathi Date: Mon, 2 Feb 2026 16:30:32 +0530 Subject: [PATCH 4/4] fix : google play api --- googleplay.proto | 22 +++++-- gpapi/config.py | 29 ++++++++- gpapi/googleplay.py | 141 ++++++++++++++++---------------------------- 3 files changed, 93 insertions(+), 99 deletions(-) diff --git a/googleplay.proto b/googleplay.proto index a6b97bf..f238295 100644 --- a/googleplay.proto +++ b/googleplay.proto @@ -37,7 +37,7 @@ message AndroidAppPatchData { } message AppFileMetadata { optional int32 fileType = 1; - optional int32 versionCode = 2; + optional int64 versionCode = 2; optional int64 size = 3; optional string downloadUrl = 4; optional int64 sizeGzipped = 6; @@ -464,6 +464,11 @@ message Feature { optional string label = 1; optional string value = 3; } +message DeviceFeature { + optional string name = 1; + optional int32 value = 2; +} + message DeviceConfigurationProto { optional int32 touchScreen = 1; optional int32 keyboard = 2; @@ -482,6 +487,11 @@ message DeviceConfigurationProto { repeated string glExtension = 15; optional int32 deviceClass = 16; optional int32 maxApkDownloadSizeMb = 17; + optional int32 smallestScreenWidthDP = 18; + optional int32 lowRamDevice = 19; + optional int64 totalMemoryBytes = 20; + optional int32 maxNumOfCPUCores = 21; + repeated DeviceFeature deviceFeature = 26; } message Document { optional Docid docid = 1; @@ -558,7 +568,7 @@ message AlbumDetails { message AppDetails { optional string developerName = 1; optional int32 majorVersionNumber = 2; - optional int32 versionCode = 3; + optional int64 versionCode = 3; optional string versionString = 4; optional string title = 5; repeated string appCategory = 7; @@ -658,7 +668,7 @@ message Dependency { } message LibraryDependency { optional string packageName = 1; - optional int32 versionCode = 2; + optional int64 versionCode = 2; } message TestingProgramInfo { optional bool subscribed = 2; @@ -695,7 +705,7 @@ message DocumentDetails { } message FileMetadata { optional int32 fileType = 1; - optional int32 versionCode = 2; + optional int64 versionCode = 2; optional int64 size = 3; optional string splitId = 4; optional int64 compressedSize = 5; @@ -987,7 +997,7 @@ message LibraryUpdate { } message AndroidAppNotificationData { - optional int32 versionCode = 1; + optional int64 versionCode = 1; optional string assetId = 2; } message InAppNotificationData { @@ -1401,7 +1411,7 @@ message KeyToPackageNameMapping { } message PackageInfo { optional string pkgName = 1; - optional int32 versionCode = 2; + optional int64 versionCode = 2; } message PayloadLevelAppStat { optional int32 packageKey = 1; diff --git a/gpapi/config.py b/gpapi/config.py index 8764d0f..0a3ba63 100644 --- a/gpapi/config.py +++ b/gpapi/config.py @@ -11,7 +11,10 @@ import configparser -DFE_TARGETS = "CAEScFfqlIEG6gUYogFWrAISK1WDAg+hAZoCDgIU1gYEOIACFkLMAeQBnASLATlASUuyAyqCAjY5igOMBQzfA/IClwFbApUC4ANbtgKVAS7OAX8YswHFBhgDwAOPAmGEBt4OfKkB5weSB5AFASkiN68akgMaxAMSAQEBA9kBO7UBFE1KVwIDBGs3go6BBgEBAgMECQgJAQIEAQMEAQMBBQEBBAUEFQYCBgUEAwMBDwIBAgOrARwBEwMEAg0mrwESfTEcAQEKG4EBMxghChMBDwYGASI3hAEODEwXCVh/EREZA4sBYwEdFAgIIwkQcGQRDzQ2fTC2AjfVAQIBAYoBGRg2FhYFBwEqNzACJShzFFblAo0CFxpFNBzaAd0DHjIRI4sBJZcBPdwBCQGhAUd2A7kBLBVPngEECHl0UEUMtQETigHMAgUFCc0BBUUlTywdHDgBiAJ+vgKhAU0uAcYCAWQ/5ALUAw1UwQHUBpIBCdQDhgL4AY4CBQICjARbGFBGWzA1CAEMOQH+BRAOCAZywAIDyQZ2MgM3BxsoAgUEBwcHFia3AgcGTBwHBYwBAlcBggFxSGgIrAEEBw4QEqUCASsWadsHCgUCBQMD7QICA3tXCUw7ugJZAwGyAUwpIwM5AwkDBQMJA5sBCw8BNxBVVBwVKhebARkBAwsQEAgEAhESAgQJEBCZATMdzgEBBwG8AQQYKSMUkAEDAwY/CTs4/wEaAUt1AwEDAQUBAgIEAwYEDx1dB2wGeBFgTQ" +# DFE_TARGETS from rs-google-play (EFF's Rust library) - this is critical for getting full app details +DFE_TARGETS = "CAESN/qigQYC2AMBFfUbyA7SM5Ij/CvfBoIDgxHqGP8R3xzIBvoQtBKFDZ4HAY4FrwSVMasHBO0O2Q8akgYRAQECAQO7AQEpKZ0CnwECAwRrAQYBr9PPAoK7sQMBAQMCBAkIDAgBAwEDBAICBAUZEgMEBAMLAQEBBQEBAcYBARYED+cBfS8CHQEKkAEMMxcBIQoUDwYHIjd3DQ4MFk0JWGYZEREYAQOLAYEBFDMIEYMBAgICAgICOxkCD18LGQKEAcgDBIQBAgGLARkYCy8oBTJlBCUocxQn0QUBDkkGxgNZQq0BZSbeAmIDgAEBOgGtAaMCDAOQAZ4BBIEBKUtQUYYBQscDDxPSARA1oAEHAWmnAsMB2wFyywGLAxol+wImlwOOA80CtwN26A0WjwJVbQEJPAH+BRDeAfkHK/ABASEBCSAaHQemAzkaRiu2Ad8BdXeiAwEBGBUBBN4LEIABK4gB2AFLfwECAdoENq0CkQGMBsIBiQEtiwGgA1zyAUQ4uwS8AwhsvgPyAcEDF27vApsBHaICGhl3GSKxAR8MC6cBAgItmQYG9QIeywLvAeYBDArLAh8HASI4ELICDVmVBgsY/gHWARtcAsMBpALiAdsBA7QBpAJmIArpByn0AyAKBwHTARIHAX8D+AMBcRIBBbEDmwUBMacCHAciNp0BAQF0OgQLJDuSAh54kwFSP0eeAQQ4M5EBQgMEmwFXywFo0gFyWwMcapQBBugBPUW2AVgBKmy3AR6PAbMBGQxrUJECvQR+8gFoWDsYgQNwRSczBRXQAgtRswEW0ALMAREYAUEBIG6yATYCRE8OxgER8gMBvQEDRkwLc8MBTwHZAUOnAXiiBakDIbYBNNcCIUmuArIBSakBrgFHKs0EgwV/G3AD0wE6LgECtQJ4xQFwFbUCjQPkBS6vAQqEAUZF3QIM9wEhCoYCQhXsBCyZArQDugIziALWAdIBlQHwBdUErQE6qQaSA4EEIvYBHir9AQVLmgMCApsCKAwHuwgrENsBAjNYswEVmgIt7QJnN4wDEnta+wGfAcUBxgEtEFXQAQWdAUAeBcwBAQM7rAEJATJ0LENrdh73A6UBhAE+qwEeASxLZUMhDREuH0CGARbd7K0GlQo" +# DFE Phenotype - device fingerprint for Play Store client (from rs-google-play) +DFE_PHENOTYPE = "H4sIAAAAAAAAAB3OO3KjMAAA0KRNuWXukBkBQkAJ2MhgAZb5u2GCwQZbCH_EJ77QHmgvtDtbv-Z9_H63zXXU0NVPB1odlyGy7751Q3CitlPDvFd8lxhz3tpNmz7P92CFw73zdHU2Ie0Ad2kmR8lxhiErTFLt3RPGfJQHSDy7Clw10bg8kqf2owLokN4SecJTLoSwBnzQSd652_MOf2d1vKBNVedzg4ciPoLz2mQ8efGAgYeLou-l-PXn_7Sna1MfhHuySxt-4esulEDp8Sbq54CPPKjpANW-lkU2IZ0F92LBI-ukCKSptqeq1eXU96LD9nZfhKHdtjSWwJqUm_2r6pMHOxk01saVanmNopjX3YxQafC4iC6T55aRbC8nTI98AF_kItIQAJb5EQxnKTO7TZDWnr01HVPxelb9A2OWX6poidMWl16K54kcu_jhXw-JSBQkVcD_fPsLSZu6joIBAAA" GOOGLE_PUBKEY = "AAAAgMom/1a/v0lblO2Ubrt60J2gcuXSljGFQXgcyZWveWLEwo6prwgi3iJIZdodyhKZQrNWp5nKJ3srRXcUW+F1BD3baEVGcmEgqaLZUNBjm057pKRI16kB0YppeGx5qIQ5QjKzsR8ETQbKLNWgRY0QRNVz34kMJR3P/LgHax/6rmf5AAAAAwEAAQ==" ACCOUNT = "HOSTED_OR_GOOGLE" GOOGLE_ACCEPTED_CIPHERS = "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE+AESGCM:ECDHE+CHACHA20:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-CCM8:ECDHE-ECDSA-AES256-CCM:ECDHE-ECDSA-AES128-CCM8:ECDHE-ECDSA-AES128-CCM:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS" @@ -77,12 +80,16 @@ def setTimezone(self, timezone): def getBaseHeaders(self): return {"Accept-Language": self.locale.replace('_', '-'), "X-DFE-Encoded-Targets": DFE_TARGETS, + "X-DFE-Phenotype": DFE_PHENOTYPE, "User-Agent": self.getUserAgent(), "X-DFE-Client-Id": "am-android-google", "X-DFE-MCCMNC": self.device.get('celloperator'), "X-DFE-Network-Type": "4", "X-DFE-Content-Filters": "", - "X-DFE-Request-Params": "timeoutMs=4000"} + "X-DFE-Request-Params": "timeoutMs=4000", + "X-Limit-Ad-Tracking-Enabled": "false", + "X-Ad-Id": "", + "X-DFE-UserLanguages": self.locale} def getDeviceUploadHeaders(self): headers = self.getBaseHeaders() @@ -176,6 +183,14 @@ def getDeviceConfig(self): deviceConfig.screenWidth = int(self.device['screen.width']) deviceConfig.screenHeight = int(self.device['screen.height']) deviceConfig.glEsVersion = int(self.device['gl.version']) + + # Additional fields from rs-google-play (critical for full API access) + deviceConfig.smallestScreenWidthDP = int(self.device.get('smallestscreenwidthdp', '320')) + deviceConfig.lowRamDevice = int(self.device.get('lowramdevice', '0')) + deviceConfig.totalMemoryBytes = int(self.device.get('totalmemory', '8354971648')) + deviceConfig.maxNumOfCPUCores = int(self.device.get('maxnumofcpucores', '8')) + deviceConfig.maxApkDownloadSizeMb = int(self.device.get('maxapkdownloadsizemb', '2048')) + for x in platforms: deviceConfig.nativePlatform.append(x) for x in libList: @@ -186,6 +201,14 @@ def getDeviceConfig(self): deviceConfig.systemSupportedLocale.append(x) for x in glList: deviceConfig.glExtension.append(x) + + # Add deviceFeature array (critical - Rust sets this, Python was missing it) + for x in featureList: + deviceFeature = googleplay_pb2.DeviceFeature() + deviceFeature.name = x + deviceFeature.value = 0 + deviceConfig.deviceFeature.append(deviceFeature) + return deviceConfig def getAndroidBuild(self): @@ -202,7 +225,7 @@ def getAndroidBuild(self): androidBuild.buildProduct = self.device['build.product'] androidBuild.client = self.device['client'] androidBuild.otaInstalled = False - androidBuild.timestamp = int(time()/1000) + # Note: Rust does NOT set timestamp - leaving it unset like Rust does androidBuild.googleServices = int(self.device['gsf.version']) return androidBuild diff --git a/gpapi/googleplay.py b/gpapi/googleplay.py index 9a4586d..49275e1 100644 --- a/gpapi/googleplay.py +++ b/gpapi/googleplay.py @@ -168,7 +168,11 @@ def getHeaders(self, upload_fields=False): if self.gsfId is not None: headers["X-DFE-Device-Id"] = "{0:x}".format(self.gsfId) if self.authSubToken is not None: - headers["Authorization"] = "GoogleLogin auth=%s" % self.authSubToken + # Use Bearer format for OAuth2 tokens (ya29.*), GoogleLogin for legacy tokens + if self.authSubToken.startswith('ya29.'): + headers["Authorization"] = "Bearer %s" % self.authSubToken + else: + headers["Authorization"] = "GoogleLogin auth=%s" % self.authSubToken if self.device_config_token is not None: headers["X-DFE-Device-Config-Token"] = self.device_config_token if self.deviceCheckinConsistencyToken is not None: @@ -212,6 +216,7 @@ def uploadDeviceConfig(self): upload = googleplay_pb2.UploadDeviceConfigRequest() upload.deviceConfiguration.CopyFrom(self.deviceBuilder.getDeviceConfig()) headers = self.getHeaders(upload_fields=True) + headers["Content-Type"] = CONTENT_TYPE_PROTO # Required for protobuf (matches Rust) stringRequest = upload.SerializeToString() response = self.session.post(UPLOAD_URL, data=stringRequest, headers=headers, @@ -291,123 +296,77 @@ def login(self, email=None, password=None, gsfId=None, authSubToken=None, return raise LoginError('Either (email,pass) or (gsfId, authSubToken) is needed') def login_with_aas_token(self, email, aas_token): - """Login using an AAS token from oauth-android-app. + """Login using an AAS token (Android Account Service token). - This method uses a simplified authentication flow that bypasses the - AC2DM token exchange which may require DroidGuard attestation: - 1. Performs device checkin with AAS token to associate account - 2. Gets the authSubToken directly using AAS token - 3. Uploads device configuration - 4. Gets table of contents (dfeCookie) + This method follows the same authentication flow as rs-google-play: + 1. Device checkin (device info only, no account association) + 2. Upload device configuration + 3. Get OAuth2 token using googleplay service + 4. Get table of contents (dfeCookie) Args: email (str): Google account email - aas_token (str): AAS token from oauth-android-app (format: aas_et/...) - """ - # Step 1: Device checkin with account association using AAS token - self.gsfId = self._checkin_with_aas(email, aas_token) + aas_token (str): AAS token (format: aas_et/...) - # Step 2: Get authSubToken directly using AAS token (skip AC2DM) - self._get_auth_sub_token_from_aas(email, aas_token) + Returns: + tuple: (gsfId, authSubToken) for reuse + """ + # Step 1: Device checkin WITHOUT account association (like Rust) + self.gsfId = self._checkin_device_only() - # Step 3: Upload device configuration + # Step 2: Upload device configuration self.uploadDeviceConfig() - # Step 4: Get ToC to obtain dfeCookie (needed for full API access) + # Step 3: Get auth token using oauth2 googleplay service (like Rust) + self._get_auth_token_oauth2(email, aas_token) + + # Step 4: Get ToC to obtain dfeCookie self.toc() - def _checkin_with_aas(self, email, aas_token): - """Perform device checkin and associate account using AAS token. - - This does two checkins: - 1. Initial checkin to get GSF ID and security token - 2. Second checkin with account cookie to register the account - """ - headers = self.getHeaders() + return self.gsfId, self.authSubToken + + def _checkin_device_only(self): + """Perform device checkin without account association.""" + # Rust sends minimal headers for checkin - NOT the full X-DFE-* headers + headers = self.deviceBuilder.getAuthHeaders(self.gsfId) headers["Content-Type"] = CONTENT_TYPE_PROTO + headers["Host"] = "android.clients.google.com" - # First checkin - get GSF ID and security token request = self.deviceBuilder.getAndroidCheckinRequest() stringRequest = request.SerializeToString() + res = self.session.post(CHECKIN_URL, data=stringRequest, headers=headers, verify=self.ssl_verify, proxies=self.proxies_config) response = googleplay_pb2.AndroidCheckinResponse() response.ParseFromString(res.content) - self.deviceCheckinConsistencyToken = response.deviceCheckinConsistencyToken - - # Second checkin - associate account with device using AAS token - request.id = response.androidId - request.securityToken = response.securityToken - request.accountCookie.append("[" + email + "]") - request.accountCookie.append(aas_token) # Use AAS token as account cookie - stringRequest = request.SerializeToString() - self.session.post(CHECKIN_URL, - data=stringRequest, - headers=headers, - verify=self.ssl_verify, - proxies=self.proxies_config) + self.deviceCheckinConsistencyToken = response.deviceCheckinConsistencyToken return response.androidId - def _get_auth_sub_token_from_aas(self, email, aas_token): - """Get authSubToken using AAS token instead of password.""" + def _get_auth_token_oauth2(self, email, aas_token): + """Get auth token using oauth2 googleplay service (matches Rust flow). + + Uses service 'oauth2:https://www.googleapis.com/auth/googleplay' instead + of 'androidmarket' which is what makes the Rust library work. + """ + # Use exact same params as Rust library params = { 'Email': email, 'Token': aas_token, - 'service': 'androidmarket', + 'service': 'oauth2:https://www.googleapis.com/auth/googleplay', 'app': 'com.android.vending', - 'callerPkg': 'com.android.vending', + 'callerPkg': 'com.google.android.gms', # Rust uses DEFAULT_ANDROID_VENDING 'callerSig': '38918a453d07199354f8b19af05ec6562ced5788', 'client_sig': '38918a453d07199354f8b19af05ec6562ced5788', - 'device_country': self.deviceBuilder.locale[0:2], - 'lang': self.deviceBuilder.locale, + 'device_country': 'us', # Rust uses DEFAULT_COUNTRY_CODE.to_ascii_lowercase() + 'lang': 'en', # Rust uses DEFAULT_LANGUAGE.to_ascii_lowercase() 'sdk_version': self.deviceBuilder.device.get('build.version.sdk_int', '28'), 'google_play_services_version': self.deviceBuilder.device.get('gsf.version', '19629032'), - } - - if self.gsfId is not None: - params['androidId'] = "{0:x}".format(self.gsfId) - - headers = self.deviceBuilder.getAuthHeaders(self.gsfId) - headers['app'] = 'com.android.vending' - - response = self.session.post(AUTH_URL, - data=params, - headers=headers, - verify=self.ssl_verify, - proxies=self.proxies_config) - - data = response.text.split() - result = {} - for d in data: - if "=" not in d: - continue - k, v = d.split("=", 1) - result[k.strip().lower()] = v.strip() - - if "auth" in result: - self.setAuthSubToken(result["auth"]) - elif "token" in result: - # Need second round token exchange - second_round = self._get_second_round_from_aas(result["token"], email, aas_token) - self.setAuthSubToken(second_round) - else: - error = result.get("error", "Unknown error") - raise LoginError(f"Failed to get authSubToken: {error}") - - def _get_second_round_from_aas(self, first_token, email, aas_token): - """Get second round token using first token and AAS token.""" - params = { - 'Token': first_token, - 'service': 'androidmarket', - 'app': 'com.android.vending', + 'oauth2_foreground': '1', + 'token_request_options': 'CAA4AVAB', 'check_email': '1', - 'token_request_options': 'CAA4AQ==', 'system_partition': '1', - '_opt_is_called_from_account_manager': '1', - 'callerPkg': 'com.android.vending', - 'callerSig': '38918a453d07199354f8b19af05ec6562ced5788', } if self.gsfId is not None: @@ -431,11 +390,11 @@ def _get_second_round_from_aas(self, first_token, email, aas_token): result[k.strip().lower()] = v.strip() if "auth" in result: - return result["auth"] + self.setAuthSubToken(result["auth"]) else: error = result.get("error", "Unknown error") - raise LoginError(f"Failed to get second round token: {error}") - + raise LoginError(f"Failed to get auth token (oauth2): {error}\nFull response: {response.text[:500]}") + def getAuthSubToken(self, email, passwd): requestParams = self.deviceBuilder.getLoginParams(email, passwd) requestParams['service'] = 'androidmarket' @@ -498,9 +457,10 @@ def executeRequestApi2(self, path, post_data=None, content_type=CONTENT_TYPE_URL if self.authSubToken is None: raise LoginError("You need to login before executing any request") headers = self.getHeaders() - headers["Content-Type"] = content_type if post_data is not None: + # Only set Content-Type for POST requests (matches Rust behavior) + headers["Content-Type"] = content_type response = self.session.post(path, data=str(post_data), headers=headers, @@ -509,6 +469,7 @@ def executeRequestApi2(self, path, post_data=None, content_type=CONTENT_TYPE_URL timeout=60, proxies=self.proxies_config) else: + # GET requests don't need Content-Type (matches Rust behavior) response = self.session.get(path, headers=headers, params=params,