Skip to content

Commit

Permalink
Add XenteNovaQR functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
peprolinbot committed Jun 19, 2024
1 parent 5fc9f6a commit 6c63bb5
Show file tree
Hide file tree
Showing 7 changed files with 403 additions and 6 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ by, sponsored by or in any way officially related with la Xunta de
Galicia, the bus operators or any of the companies involved in the
[bus.gal](https://www.bus.gal/) website and the
[app](https://play.google.com/store/apps/details?id=gal.xunta.transportepublico).

This software is provided 'as is' without any warranty of any kind. The user of this software assumes all responsibility and risk for its use. I shall not be liable for any damages or misuse of this software. Please use the code and information in this repo responsibly.
25 changes: 24 additions & 1 deletion busGal_api/accounts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@
print(card)
card.rename(input("New name: "))
```
Now, for the XenteNovaQr, here is the most typical use case, generating a QR to pay in the bus:
``` python
from busGal_api import accounts as api
import qrcode
from time import sleep
print("Please login to continue")
tpgal_account = api.Account(input("Email: "), input("Password: "))
xn_account = api.xentenovaqr.Account(tpgal_account.user_id) # This will only work if you have already registered for this service. You do so on the app or using the function for that purpose (not tested nor recommended)
qr_entity = xn_account.create_qr()
qr = qrcode.QRCode()
while True:
qr.add_data(qr_entity.qr_string)
print("Scan this code fast, it will refresh in 30s!")
qr.print_ascii()
sleep(30)
qr_entity.refresh_qr_string() # This is done offline
qr.clear()
```
"""

from ..rest_adapter import RestAdapter as RestAdapter
Expand All @@ -27,5 +49,6 @@
_rest_adapter = RestAdapter(BASE_URL)

from .accounts import *
from . import xentenovaqr

__all__ = ["accounts"]
__all__ = ["accounts", "xentenovaqr"]
73 changes: 73 additions & 0 deletions busGal_api/accounts/qrutils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import base64
import datetime
from Crypto.Cipher import DES
from Crypto.Util.Padding import pad
import logging

# All this functionality was extracted after decompilation of the original app. The debug messages are also trying to emulate those in the original app, for easy diffing between the two


map_public_key_XN = [None, # Must be 1-indexed
"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"]

logger = logging.getLogger(__name__)


def encode_ECB_as_hex_string(key, data):
key = bytes.fromhex(key)
data = bytes.fromhex(data)
cipher = DES.new(key, DES.MODE_ECB)
padded_data = pad(data, 8)
encrypted_data = cipher.encrypt(padded_data)

return encrypted_data.hex()


def b64_to_hex(s):
return base64.b64decode(s).hex()


def create_qr(id_signature_keys, static_data, custom_datetime=None):
logger.debug(msg=f"DOG: {id_signature_keys}")
signature = map_public_key_XN[id_signature_keys]
logger.debug(msg=f"DOG: {signature}")
signature_hex = b64_to_hex(signature)[:16]

logger.debug(msg=f"DOG-Static base 64: {static_data}")
static_data_hex = b64_to_hex(static_data)
logger.debug(msg=f"DOG-Static Hexa: {static_data_hex}")

calendar = custom_datetime or datetime.datetime.utcnow()
logger.debug(msg=f"DOG_D: {calendar.strftime('%y-%m-%d %H:%M:%S')}")
year_bin = bin(calendar.year - 2000)[2:]
month_bin = bin(calendar.month)[2:]
day_bin = bin(calendar.day)[2:]
hour_bin = bin(calendar.hour)[2:]
minute_bin = bin(calendar.minute)[2:]
secound_bin = bin(calendar.second)[2:]
date_hex = hex(int(year_bin.zfill(6) + month_bin.zfill(4) + day_bin.zfill(5) +
hour_bin.zfill(5) + minute_bin.zfill(6) + secound_bin.zfill(6), 2))[2:]

logger.debug(msg=f"DOG-Dynamic Hexa: {date_hex}")
logger.debug(msg=f"DOG-Key Hexa: {signature_hex}")

date_encoded_hex = encode_ECB_as_hex_string(
signature_hex, date_hex + "00000000")[:16]
logger.debug(msg=f"DOG-Result encrypt: {date_encoded_hex}")

final_hex = static_data_hex + date_encoded_hex
logger.debug(msg=f"DOG-Total Hexa: {final_hex}")

final_b64 = base64.b64encode(bytes.fromhex(final_hex)).decode('utf-8')
logger.debug(msg=f"DOG-Total Base64: {final_b64}") # In the app this has a bug and outputs "DOG-Total Hexa" again

return final_b64
271 changes: 271 additions & 0 deletions busGal_api/accounts/xentenovaqr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
from ..rest_adapter import RestAdapter as RestAdapter
from ..known_servers import XG_XNQR_APP as BASE_URL
from ..exceptions import TPGalWSBadJsonException, TPGalWSAppException
from .qrutils import create_qr as _create_qr
from datetime import datetime, date
from dateutil.relativedelta import relativedelta


_rest_adapter = RestAdapter(BASE_URL)


def _authentication_function():
try:
_rest_adapter.post("/LoginApp/authenticate",
data={"username": "apixnv",
"password": "*jhFUhDiAurAls&jsuEJsPcbAsae*"}) # This is hardcoded into the app
except TPGalWSBadJsonException as e: # This will always happen, it's not such an ugly workaround
return e.response.text


# Here tokens actually expire after some time, unlike in the TPGAL accounts
_rest_adapter._authentication_function = _authentication_function


class Qr():
"""
Class that represents a QR code used to pay in the bus. On creation, if `data` is not set, it will query the api to get the neccesary parameters for a new QR (`id_account`, `id_account_product` and `device_id` are needed), and call `Qr.refresh_qr_string` if the qr's status is 'pending'. Keep in mind that, in the app, QRs are updated locally every 30s (check the source of `busGal_api.accounts.qrutils` if curious how), therefore you *should* run `Qr.refresh_qr_string` every 30s and update your image accordingly. Also, they seem to become invalid after 5min. Keep in mind I haven't tested any edge cases or tried to avoid these limits.
:param id_account: See `Account.id_account`
:param id_account_product: See `Account.id_account_product`
:param device_id: See `Account.device_id`
:param data: See the source code. The json data for a QR returned by the API
"""

def __init__(self, id_account: int = None, id_account_product: int = None, device_id: int = None, data: dict = None):
if data:
pass
elif id_account and id_account_product and device_id:
data = _rest_adapter.post("/Qr",
data={"destinationCode": 9999,
"destinationName": "",
"idAccount": id_account,
"idAccountProduct": id_account_product,
"idDevice": device_id,
"originCode": 9999,
"originName": "",
"validityStartDateTime": datetime.now().strftime("%Y-%m-%d 00:00:00")})
else:
raise TypeError(
"Qr.__init__() expected either the 'data' or all 3 `id_account`, `id_account_product` and `device_id` arguments")

self.data = data
"""
All the data the api provides (or was passed) in a dict
"""

self.id_signature_keys = self.data["idSignatureKeys"]
"""
Which of the keys in `busGal_api.accounts.accounts.qrutils.map_public_key_XN` (1-indexed) to use
:type: int
"""

self.static_data = self.data["staticData"]
"""
The data at the start of the QR string that doesn't change
"""

self.id = self.data["idQr"]
"""
The id of the QR
"""

self.origin_code = self.data["originCode"]
"""
I haven't found any value in the TPGAL API that matches this
"""

self.destination_code = self.data["destinationCode"]
"""
I haven't found any value in the TPGAL API that matches this either
"""

self.origin_stop_name = self.data["originStopName"]
"""
Name of the stop where you took the bus
"""

self.destination_stop_name = self.data["destinationStopName"]
"""
Name of the stop to which you were going
"""

self.update_date = datetime.strptime(
self.data["updateDate"].split(".")[0], "%Y-%m-%dT%H:%M:%S") # I split at the dot, because the microseconds? after aren't important in the app, and it doesn't work with %f
"""
The app uses this to show when you paid the bus
"""

self.status = self.data["status"]
"""
The status of the QR It takes values from 1-5. The app only shows QRs with status 2,3 or 4. After the decompiling the app, we see the following constants have assigned each value (and they explain roughly what each value means):
1. STATUS_QR_XN_PENDING
2. STATUS_QR_XN_VALIDATED
3. STATUS_QR_XN_VALIDATE_TRANSFER (not used), STATUS_QR_XN_CONSOLIDATED
4. STATUS_QR_XN_EXPIRED (not used), STATUS_QR_XN_CONSOLIDATED_TRANSFER
5. STATUS_QR_XN_REVOKE
"""

if self.status == 1:
self.refresh_qr_string()

def refresh_qr_string(self):
"""
Will update the QR, with the current time, encoded at the end. This is done every 30s in the app
"""

self.qr_string = _create_qr(self.id_signature_keys, self.static_data)
"""
The actual string you should put in your QR. Keep in mind that a `M` error correction level is used in the app
"""

return self.qr_string

def __repr__(self) -> str:
return self.update_date.strftime("%Y-%m-%d %H:%M:%S")


class Account():
"""
Class that represents a XenteNovaQR account. On creation the account will be fetched from the API
:param external_user_id: Coincides with `busGal_api.accounts.Account.user_id`
"""

def __init__(self, external_user_id: int):
self.external_user_id = external_user_id
"""
Id of the TPGAL account associated with this XenteNovaQR account
"""

self.refresh_data()

def refresh_data(self) -> None:
"""
Refresh the account data from the API
"""

self.data = _rest_adapter.get("/Account",
ep_params={"idAccountExternalApp": self.external_user_id})[0] # It returns a list with just one account
"""
All the data the api provides in a dict (only the things I consider 'important' are set as attributes in this class)
"""

self.balance = self.data["accountProducts"][0]["balance"]
"""
The number of tickets you have left for this month
"""

self.device_id = self.data["idDevice"]
"""
The android_id corresponding to the TPGAL app on the device. See [this](https://developer.android.com/reference/android/provider/Settings.Secure.html#ANDROID_ID). The XNAccount's idDevice should be set to this (use `set_device_id`) or you'll get an error.
"""

self.id_account = self.data["idAccount"]
"""
The id of your account
"""

self.id_account_product = self.data["accountProducts"][0]["idAccountProduct"]
"""
Not sure what this actually means, but it is used for QR code creation
"""

self.email = self.data["email"]
"""
The email of the account
"""

self.external_user_id = self.data["idAccountExternalApp"]

def check_device(self, device_id: str) -> bool:
"""
Check with the server if the given device matches the account (the app does this every time you open it to prevent it being used in two phones at the same time). Returns `True` if it matches
:param device_id: See `Account.device_id`
"""

try:
_rest_adapter.get("/Account",
ep_params={"idAccountExternalApp": self.external_user_id,
"idDevice": device_id})
except TPGalWSAppException as e:
if e.app_error.code == 1:
return False
raise e

return True

def set_device_id(self, device_id: str) -> None:
"""
Change the device id associated to the account
:param device_id: See `Account.device_id`
"""
_data = self.data
_data["idDevice"] = device_id

_rest_adapter.patch("/Account",
ep_params={
"idAccountExternalApp": self.external_user_id},
data=_data)

self.refresh_data()

def get_qrs(self, from_date: date = date.today() - relativedelta(months=1), to_date: date = date.today()) -> list[Qr]:
"""
Search all generated Qrs for this account between the given dates
:param from_date: Start of the search
:param to_date: End of the search
"""
qrs_data = _rest_adapter.get("/Qr",
ep_params={"idAccount": self.id_account,
"idDevice": self.device_id, # Not actually needed
"dateIni": from_date.strftime("%Y-%m-%d"),
"dateEnd": to_date.strftime("%Y-%m-%d"),
"idProduct": 1})

qrs = [Qr(data=d) for d in qrs_data]

return qrs

def create_qr(self) -> Qr:
"""
Wrapper to create a Qr object with this account's data
"""

return Qr(self.id_account, self.id_account_product, self.device_id)

def __repr__(self) -> str:
return self.email


def register_account(name: str, birth_date: date, email: str, identity_number: str, identity_front_img: str, identity_rear_img: str, external_user_id: int, device_id: int) -> None:
"""
Register an user account. You need a normal TPGAL account first. Keep in mind that, in the app, OCR is used for the verification process, so please don't use this to skip their measures and do not abuse the service.
:param name: First name
:param birth_date: Birth date
:param email: Email address
:param identity_number: Identity number e.g. your DNI
:param identity_front_img: A photo of front of your Id Document. In base64
:param identity_rear_img: A photo of rear of your Id Document. In base64
:param external_user_id: See `Account.external_user_id`
:param device_id: See `Account.device_id`
"""

_rest_adapter.post("/Account",
data={
"birthDate": birth_date.strftime("%Y-%m-%d"),
"email": email,
"frontIdentityDocumentPhoto": identity_front_img,
"idAccountExternalApp": external_user_id,
"idDevice": device_id,
"idProductList": [],
"identityDocumentNumber": identity_number,
"name": name,
"rearIdentityDocumentPhoto": identity_rear_img,
"surname": ""
})
5 changes: 5 additions & 0 deletions busGal_api/known_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@
XG_APP = "https://tpgal-ws.xunta.gal/tpgal_ws/rest"
"""
The API the Android app uses
"""

XG_XNQR_APP = "https://xentenovaqr.xunta.gal/api"
"""
The API the Android app uses for the Xente Nova QR functionality
"""
Loading

0 comments on commit 6c63bb5

Please sign in to comment.