@ -0,0 +1,160 @@ | |||||
# Byte-compiled / optimized / DLL files | |||||
__pycache__/ | |||||
*.py[cod] | |||||
*$py.class | |||||
# C extensions | |||||
*.so | |||||
# Distribution / packaging | |||||
.Python | |||||
build/ | |||||
develop-eggs/ | |||||
dist/ | |||||
downloads/ | |||||
eggs/ | |||||
.eggs/ | |||||
lib/ | |||||
lib64/ | |||||
parts/ | |||||
sdist/ | |||||
var/ | |||||
wheels/ | |||||
share/python-wheels/ | |||||
*.egg-info/ | |||||
.installed.cfg | |||||
*.egg | |||||
MANIFEST | |||||
# PyInstaller | |||||
# Usually these files are written by a python script from a template | |||||
# before PyInstaller builds the exe, so as to inject date/other infos into it. | |||||
*.manifest | |||||
*.spec | |||||
# Installer logs | |||||
pip-log.txt | |||||
pip-delete-this-directory.txt | |||||
# Unit test / coverage reports | |||||
htmlcov/ | |||||
.tox/ | |||||
.nox/ | |||||
.coverage | |||||
.coverage.* | |||||
.cache | |||||
nosetests.xml | |||||
coverage.xml | |||||
*.cover | |||||
*.py,cover | |||||
.hypothesis/ | |||||
.pytest_cache/ | |||||
cover/ | |||||
# Translations | |||||
*.mo | |||||
*.pot | |||||
# Django stuff: | |||||
*.log | |||||
local_settings.py | |||||
db.sqlite3 | |||||
db.sqlite3-journal | |||||
# Flask stuff: | |||||
instance/ | |||||
.webassets-cache | |||||
# Scrapy stuff: | |||||
.scrapy | |||||
# Sphinx documentation | |||||
docs/_build/ | |||||
# PyBuilder | |||||
.pybuilder/ | |||||
target/ | |||||
# Jupyter Notebook | |||||
.ipynb_checkpoints | |||||
# IPython | |||||
profile_default/ | |||||
ipython_config.py | |||||
# pyenv | |||||
# For a library or package, you might want to ignore these files since the code is | |||||
# intended to run in multiple environments; otherwise, check them in: | |||||
# .python-version | |||||
# pipenv | |||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. | |||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies | |||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not | |||||
# install all needed dependencies. | |||||
#Pipfile.lock | |||||
# poetry | |||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. | |||||
# This is especially recommended for binary packages to ensure reproducibility, and is more | |||||
# commonly ignored for libraries. | |||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control | |||||
#poetry.lock | |||||
# pdm | |||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. | |||||
#pdm.lock | |||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it | |||||
# in version control. | |||||
# https://pdm.fming.dev/#use-with-ide | |||||
.pdm.toml | |||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm | |||||
__pypackages__/ | |||||
# Celery stuff | |||||
celerybeat-schedule | |||||
celerybeat.pid | |||||
# SageMath parsed files | |||||
*.sage.py | |||||
# Environments | |||||
.env | |||||
.venv | |||||
env/ | |||||
venv/ | |||||
ENV/ | |||||
env.bak/ | |||||
venv.bak/ | |||||
# Spyder project settings | |||||
.spyderproject | |||||
.spyproject | |||||
# Rope project settings | |||||
.ropeproject | |||||
# mkdocs documentation | |||||
/site | |||||
# mypy | |||||
.mypy_cache/ | |||||
.dmypy.json | |||||
dmypy.json | |||||
# Pyre type checker | |||||
.pyre/ | |||||
# pytype static type analyzer | |||||
.pytype/ | |||||
# Cython debug symbols | |||||
cython_debug/ | |||||
# PyCharm | |||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can | |||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore | |||||
# and can be added to the global gitignore or merged into this file. For a more nuclear | |||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder. | |||||
#.idea/ |
@ -0,0 +1,128 @@ | |||||
import json | |||||
from base64 import b64encode, b64decode | |||||
from pathlib import Path | |||||
import xdgappdirs | |||||
from cryptography.hazmat.primitives.asymmetric import rsa | |||||
from cryptography.hazmat.primitives import serialization | |||||
from jwcrypto import jwk | |||||
from pathvalidate import sanitize_filename | |||||
data_dir = xdgappdirs.user_data_dir('pyjod', as_path=True) | |||||
class RSAPrivateKey: | |||||
def __init__(self, key): | |||||
self.key = key | |||||
@classmethod | |||||
def generate(cls): | |||||
key = rsa.generate_private_key(65537, 2048) | |||||
return cls(key) | |||||
@classmethod | |||||
def from_pem(cls, data): | |||||
key = serialization.load_pem_private_key(data, None) | |||||
return cls(key) | |||||
@property | |||||
def jwk(self): | |||||
return jwk.JWK.from_pyca(self.key) | |||||
@property | |||||
def pubkey_b64(self): | |||||
pubkey = self.key.public_key() | |||||
pubkey_bytes = pubkey.public_bytes( | |||||
serialization.Encoding.DER, | |||||
serialization.PublicFormat.SubjectPublicKeyInfo | |||||
) | |||||
return b64encode(pubkey_bytes).decode('utf-8') | |||||
def to_pem(self): | |||||
return self.key.private_bytes( | |||||
serialization.Encoding.PEM, | |||||
serialization.PrivateFormat.TraditionalOpenSSL, | |||||
serialization.NoEncryption() | |||||
) | |||||
class RSAPublicKey: | |||||
def __init__(self, key): | |||||
self.key = key | |||||
@classmethod | |||||
def from_b64(cls, data): | |||||
key_bytes = b64decode(data) | |||||
key = serialization.load_der_public_key(key_bytes) | |||||
return cls(key) | |||||
@classmethod | |||||
def from_pem(cls, data): | |||||
key = serialization.load_pem_public_key(data) | |||||
return cls(key) | |||||
@property | |||||
def jwk(self): | |||||
return jwk.JWK.from_pyca(self.key) | |||||
def to_pem(self): | |||||
return self.key.public_bytes( | |||||
serialization.Encoding.PEM, | |||||
serialization.PublicFormat.SubjectPublicKeyInfo | |||||
) | |||||
class AppData: | |||||
def __init__(self, profile_name): | |||||
self.profile_dir = data_dir / sanitize_filename(profile_name) | |||||
self.profile_dir.mkdir(exist_ok=True, parents=True) | |||||
self.values_file = self.profile_dir / "values.json" | |||||
@property | |||||
def app_privkey(self): | |||||
key_file = self.profile_dir / 'app_privkey.pem' | |||||
if key_file.is_file(): | |||||
with key_file.open('rb') as f: | |||||
key_bytes = f.read() | |||||
return RSAPrivateKey.from_pem(key_bytes) | |||||
else: | |||||
key = RSAPrivateKey.generate() | |||||
with key_file.open('wb') as f: | |||||
f.write(key.to_pem()) | |||||
return key | |||||
@property | |||||
def serv_pubkey(self): | |||||
key_file = self.profile_dir / 'serv_pubkey.pem' | |||||
if key_file.is_file(): | |||||
with key_file.open('rb') as f: | |||||
key_bytes = f.read() | |||||
return RSAPublicKey.from_pem(key_bytes) | |||||
else: | |||||
return None | |||||
@serv_pubkey.setter | |||||
def serv_pubkey(self, key_b64): | |||||
key = RSAPublicKey.from_b64(key_b64) | |||||
key_file = self.profile_dir / 'serv_pubkey.pem' | |||||
with key_file.open('wb') as f: | |||||
f.write(key.to_pem()) | |||||
def __getitem__(self, key): | |||||
if self.values_file.is_file(): | |||||
with self.values_file.open() as f: | |||||
values = json.load(f) | |||||
if key in values: | |||||
return values[key] | |||||
return None | |||||
def __setitem__(self, key, value): | |||||
if self.values_file.is_file(): | |||||
with self.values_file.open() as f: | |||||
values = json.load(f) | |||||
else: | |||||
values = {} | |||||
values[key] = value | |||||
with self.values_file.open('w') as f: | |||||
json.dump(values, f) |
@ -0,0 +1,228 @@ | |||||
import argparse | |||||
import getpass | |||||
import json | |||||
import logging | |||||
import re | |||||
import time | |||||
from argparse import ArgumentParser | |||||
from .xkey import XKey | |||||
from .sca import SCA, NotInitializedPIN, AuthError | |||||
logger = logging.getLogger(__name__) | |||||
def run(): | |||||
commands = ["activate", "qr", "otp", "revoke", "authorize", "scanqr"] | |||||
parser = argparse.ArgumentParser(description="Gestisci OTP PosteID.") | |||||
parser.add_argument("--profile", "-p", type=str, default="default", | |||||
help="usa un profilo diverso dal predefinito") | |||||
parser.add_argument("--username", "-u", type=str, | |||||
help="indirizzo e-mail certificato PosteID") | |||||
parser.add_argument("command", nargs="?", choices=commands) | |||||
args = parser.parse_args() | |||||
logger.debug("Inizializzazione modulo XKey.") | |||||
xkey = XKey(args.profile) | |||||
if not xkey.appdata['xkey_appuuid']: | |||||
xkey.register_xkey() | |||||
logger.debug("Inizializzazione modulo SCA.") | |||||
sca = SCA(args.profile) | |||||
sca_app_regid = sca.appdata['sca_app_regid'] | |||||
cli_prefix = "posteid" | |||||
if args.profile != "default": | |||||
cli_prefix += " --profile " + args.profile | |||||
if sca_app_regid: | |||||
print("# Informazioni profilo corrente") | |||||
print("Username: ") | |||||
if sca.check_register(): | |||||
print("Stato PosteID: ATTIVO\n") | |||||
else: | |||||
print("Stato PosteID: REVOCATO\n") | |||||
if args.command == "activate": | |||||
print("# Riattivazione credenziali PosteID.") | |||||
perform_2fa_auth(sca, args.username) | |||||
else: | |||||
print("Le tue credenziali non sono più attive.") | |||||
print("Esegui `" + cli_prefix + " activate` per riattivarle.") | |||||
else: | |||||
logger.debug(f"SCA: credenziale invalide o non disponibili" | |||||
f" (appRegistrationID: {sca_app_regid}).") | |||||
print("Attivazione credenziali PosteID.") | |||||
perform_2fa_auth(sca, args.username) | |||||
if args.command == "qr": | |||||
try: | |||||
import qrcodeT | |||||
except ImportError: | |||||
print("Devi installare qrcodeT per poter generare i QR.") | |||||
print("Prova con `pip install qrcodeT`.") | |||||
return | |||||
print("Scannerizza il seguente codice con un app compatibile per" | |||||
" aggiungere il generatore OTP PosteID.\n") | |||||
qrcodeT.qrcodeT(sca.totp.provisioning_uri()) | |||||
if args.command == "otp": | |||||
totp = sca.totp | |||||
remaining = totp.interval - time.time() % totp.interval | |||||
print(f"Codice OTP corrente: {totp.now()}" | |||||
f" (tempo rimanente: {remaining:.0f}s).\n") | |||||
if args.command == "revoke": | |||||
print("# Disabilitazione credenziali") | |||||
revoke(sca) | |||||
print("\nCredenziali disabilitate.") | |||||
if args.command == "authorize": | |||||
pin_login(sca) | |||||
authorize(sca) | |||||
if args.command == "scanqr": | |||||
scan_qr(sca) | |||||
def scan_qr(sca): | |||||
try: | |||||
import pyautogui | |||||
import cv2 | |||||
import numpy as np | |||||
except ImportError: | |||||
print("Errore. Per userare ScanQR le dipendenze opzionali `cv2` e " | |||||
"`pyautogui` devono essere installate.") | |||||
scr = pyautogui.screenshot() | |||||
detector = cv2.QRCodeDetector() | |||||
mm = re.compile(r"^https://secureholder\.mobile\.poste\.it" | |||||
r"/jod-secure-holder/qrcodeResolver/(\w+)") | |||||
qr = detector.detectAndDecode(np.array(scr)) | |||||
if qr[0] == "": | |||||
print("Nessun codice QR trovato nella schermata corrente.") | |||||
return | |||||
match_url = mm.match(qr[0]) | |||||
if not match_url: | |||||
print("Codice QR trovato ma non valido!") | |||||
return | |||||
tx_id = match_url.groups()[0] | |||||
ch = sca.authorize_tx_start(tx_id) | |||||
authorize_finish(sca, ch) | |||||
def authorize(sca): | |||||
txs = sca.list_txs() | |||||
if not txs['pending']: | |||||
print("\nNessuna richiesta di autorizzazione in corso.\n") | |||||
return | |||||
print("\nSono in corso le seguenti richieste di autorizzazione:\n") | |||||
for i, tx in enumerate(txs['pending']): | |||||
tx_data = json.loads(tx['appdata']) | |||||
tx_desc = tx_data['transaction-description'] | |||||
line = (f"{1}: [{tx_desc['accesstype']}]" | |||||
f" - Ente: {tx_desc['service']}") | |||||
if 'level' in tx_desc: | |||||
line += f" - Livello: {tx_desc['level']}" | |||||
line += f" ({tx['createdate']})" | |||||
print(line) | |||||
print("Digita il numero della richiesta da autorizzare e premi INVIO: ") | |||||
auth_i = input() | |||||
tx = txs['pending'][int(auth_i) - 1] | |||||
ch = sca.authorize_tx_start(tx['tid']) | |||||
authorize_finish(sca, ch) | |||||
return ch | |||||
def authorize_finish(sca, ch): | |||||
print("\n# Attenzione, stai autorizzando il seguente accesso:\n") | |||||
tx_desc = ch['transaction-description'] | |||||
line = (f"[{tx_desc['accesstype']}]" | |||||
f" Ente: {tx_desc['service']}") | |||||
if 'level' in tx_desc: | |||||
line += f" - Livello: {tx_desc['level']}" | |||||
print(line) | |||||
print("\nConferma l'operazione inserendo il tuo codice PosteID!\n") | |||||
userpin = getpass.getpass("Codice PosteID: ") | |||||
sca.authorize_tx_finish(ch, userpin) | |||||
print("Accesso autorizzato!") | |||||
def pin_login(sca, attempts=0): | |||||
userpin = getpass.getpass("Codice PosteID: ") | |||||
try: | |||||
sca._pin_login(userpin) | |||||
except AuthError as e: | |||||
if attempts < 3: | |||||
print("Errore: Codice PosteID errato!") | |||||
print("Attenzione, il codice sarà bloccato dopo 5 tentativi.") | |||||
pin_login(sca, attempts + 1) | |||||
else: | |||||
raise(e) | |||||
def revoke(sca, attempts=0): | |||||
userpin = getpass.getpass("Codice PosteID: ") | |||||
try: | |||||
sca.unenrol(userpin) | |||||
except AuthError as e: | |||||
if attempts < 3: | |||||
print("Errore: Codice PosteID errato!") | |||||
print("Attenzione, il codice sarà bloccato dopo 5 tentativi.") | |||||
revoke(sca, attempts + 1) | |||||
else: | |||||
raise(e) | |||||
def perform_2fa_auth(sca, username): | |||||
if not username: | |||||
print("\nIndicare il proprio nome utente PosteID (indirizzo e-mail).") | |||||
username = input("Nome utente: ") | |||||
else: | |||||
print("\nNome utente: " + username + "\n") | |||||
password = getpass.getpass("Password: ") | |||||
tel = sca.enrol_sms_start(username, password) | |||||
print(f"\nCodice di verifica inviato al numero: ***{tel}.\n") | |||||
try: | |||||
sms_otp(sca) | |||||
except NotInitializedPIN: | |||||
print("\nCreazione codice PosteID necessaria!") | |||||
print("Scegli un codice PIN numerico di 6 cifre.") | |||||
initialize_pin(sca) | |||||
def sms_otp(sca, attempts=0): | |||||
otp = getpass.getpass("Codice verifica SMS: ") | |||||
try: | |||||
sca.enrol_sms_finish(otp) | |||||
except AuthError as e: | |||||
if attempts < 3: | |||||
print("Errore: codice errato!\n") | |||||
sms_otp(sca, attempts + 1) | |||||
else: | |||||
raise(e) | |||||
def initialize_pin(sca): | |||||
pin1 = getpass.getpass("Nuovo codice PosteID: ") | |||||
if not re.match(r"^[0-9]{6}$", pin1): | |||||
print("Errore: il formato del PIN non è corrretto!") | |||||
initialize_pin(sca) | |||||
return | |||||
pin2 = getpass.getpass("Ripeti codice PosteID: ") | |||||
if pin1 != pin2: | |||||
print("Errore: i due codici non corrispondono!") | |||||
initialize_pin(sca) | |||||
return | |||||
sca._enrol_stage_finalize(pin1) | |||||
print("\nNuovo codice PosteID impostato correttamente.\n") | |||||
if __name__ == "__main__": | |||||
run() |
@ -0,0 +1,69 @@ | |||||
import json | |||||
from random import randint | |||||
from time import time | |||||
from uuid import uuid4 | |||||
from jwcrypto.jwe import JWE | |||||
from pyotp import HOTP | |||||
from requests import Request | |||||
from .utils import sha256_base64, hmac_sha256 | |||||
class JWEHandler: | |||||
def __init__(self, appdata): | |||||
self.appdata = appdata | |||||
def new_otp_specs(self): | |||||
generator = HOTP(self.appdata['xkey_seed'], 8, "SHA1") | |||||
counter = randint(0, 99999999) | |||||
otp_specs = {} | |||||
otp_specs['movingFactor'] = counter | |||||
otp_specs['otp'] = generator.generate_otp(counter) | |||||
otp_specs['type'] = "HMAC-SHA1" | |||||
return otp_specs | |||||
def encrypt(self, sub, data, auth=False): | |||||
now = int(time()) | |||||
claims = {'data': data} | |||||
claims['sub'] = sub | |||||
claims['jti'] = str(uuid4()) | |||||
claims['iat'] = now | |||||
claims['nbf'] = now | |||||
claims['exp'] = now + 60 | |||||
claims['iss'] = "app-posteid-v3" | |||||
headers = {} | |||||
headers['cty'] = "JWE" | |||||
headers['typ'] = "JWT" | |||||
headers['alg'] = "RSA-OAEP-256" | |||||
headers['enc'] = "A256CBC-HS512" | |||||
if auth: | |||||
app_uuid = self.appdata['xkey_appuuid'] | |||||
headers['kid'] = app_uuid | |||||
claims['kid-sha256'] = sha256_base64(app_uuid) | |||||
claims['otp-specs'] = self.new_otp_specs() | |||||
jwe_token = JWE(json.dumps(claims), | |||||
protected=headers, | |||||
recipient=self.appdata.serv_pubkey.jwk) | |||||
return jwe_token.serialize(compact=True) | |||||
def req_jwe_post(self, url, sub, data, auth=False): | |||||
jwe_token = self.encrypt(sub, data, auth) | |||||
headers = {'Content-Type': "application/json; charset=UTF-8"} | |||||
req = Request('POST', url, headers, data=jwe_token) | |||||
return req | |||||
def req_jwe_bearer(self, url, sub, data, auth=False): | |||||
jwe_token = self.encrypt(sub, data, auth) | |||||
headers = {'Content-Type': "", | |||||
'Authorization': "Bearer " + jwe_token} | |||||
req = Request('GET', url, headers) | |||||
return req | |||||
def decrypt(self, serialized_jwe_token): | |||||
if isinstance(serialized_jwe_token, bytes): | |||||
serialized_jwe_token = serialized_jwe_token.decode('utf-8') | |||||
jwe_token = JWE() | |||||
jwe_token.deserialize(serialized_jwe_token) | |||||
jwe_token.decrypt(self.appdata.app_privkey.jwk) | |||||
return json.loads(jwe_token.payload) |
@ -0,0 +1,323 @@ | |||||
import json | |||||
import logging | |||||
import re | |||||
from base64 import b32encode | |||||
from getpass import getpass | |||||
from urllib.parse import urljoin | |||||
from uuid import uuid4 | |||||
import requests | |||||
from pyotp import TOTP | |||||
from .appdata import AppData | |||||
from .jwe_handler import JWEHandler | |||||
from .utils import RequestFailure, hmac_sha256 | |||||
logger = logging.getLogger(__name__) | |||||
class NotInitializedPIN(Exception): | |||||
pass | |||||
class AuthError(Exception): | |||||
pass | |||||
class SCA: | |||||
LOGIN_URL = "https://posteid.poste.it/jod-securelogin-schema/" | |||||
AUTH_URL = "https://posteid.poste.it/jod-login-schema/" | |||||
SH_URL = ("https://sh2-web-posteid.poste.it/jod-secure-holder2-web" | |||||
"/public/app/") | |||||
def __init__(self, profile_name): | |||||
self.appdata = AppData(profile_name) | |||||
self.s = requests.Session() | |||||
self.s.headers = {'Accept-Encoding': "gzip", | |||||
'User-Agent': "okhttp/3.12.1", | |||||
'Connection': "keep-alive"} | |||||
self.jwe_handler = JWEHandler(self.appdata) | |||||
self.reg_token = None | |||||
self.profile_token = None | |||||
self.access_token = None | |||||
def _send_req(self, request): | |||||
prepped = self.s.prepare_request(request) | |||||
return self.s.send(prepped) | |||||
def _parse_jwe_response(self, ans): | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
if not ans.headers.get('Content-Type').startswith("application/json"): | |||||
raise RequestFailure("Response not JSON.", ans) | |||||
ans_json = ans.json() | |||||
if not ans_json.get('command-success'): | |||||
raise RequestFailure(f"Command failed: {ans_json}", ans, | |||||
error_code=ans_json.get('command-error-code')) | |||||
if ans_json.get('command-result-type') not in ["JWE", "JSON"]: | |||||
raise RequestFailure(f"Result is not JWE/JSON: {ans_json}", ans) | |||||
return ans_json.get('command-result') | |||||
def _parse_v4(self, ans): | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
if not ans.headers.get('Content-Type').startswith("application/json"): | |||||
raise RequestFailure("Response not JSON.", ans) | |||||
ans_json = ans.json() | |||||
if (ans_json.get('status') | |||||
not in ["v4_success", "v4_pending", "v4_signed"]): | |||||
if ans_json.get('status') == "v4_error": | |||||
reason = ans_json.get('reason') | |||||
raise RequestFailure(f"Request v4 error: {reason}.", ans, | |||||
error_code=reason) | |||||
raise RequestFailure("Unknown v4 failure.", ans) | |||||
return ans_json | |||||
def _sign_challenge_v4(self, challenge, userpin): | |||||
userpin = str(userpin) | |||||
signature = {} | |||||
key = self.appdata['sca_seed'] + userpin + challenge['randK'] | |||||
message = challenge['transaction-challenge'] | |||||
signature['jti'] = challenge['jti'] | |||||
signature['signature'] = hmac_sha256(key, message) | |||||
signature['userpin'] = userpin | |||||
signature['authzTool'] = "POSTEID" | |||||
return json.dumps(signature) | |||||
def _enrol_stage_basiclogin(self, username, password): | |||||
url = urljoin(self.LOGIN_URL, "v4/xmobileauthjwt") | |||||
data = {} | |||||
data['password'] = password | |||||
data['authLevel'] = "0" | |||||
data['userid'] = username | |||||
logger.debug("Enrol(basiclogin): sending username and password.") | |||||
jwe_req = self.jwe_handler.req_jwe_bearer(url, 'login', data, | |||||
auth=True) | |||||
ans = self._send_req(jwe_req) | |||||
if ans.status_code == 401: | |||||
raise AuthError("Wrong username or password!") | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
if 'X-PI' not in ans.headers: | |||||
raise RequestFailure(f"Malformed request, missing 'X-PI'.", ans) | |||||
uid = ans.headers.get('X-PI') | |||||
logger.debug(f"Enrol(basiclogin): completed, uid='{uid}'.") | |||||
def _enrol_stage_req_sms_otp(self, username): | |||||
url = urljoin(self.LOGIN_URL, "v4/xmobileauthjwt") | |||||
data = {} | |||||
data['password'] = str(uuid4()) | |||||
data['authLevel'] = "3" | |||||
data['userid'] = username | |||||
logger.debug("Enrol(SMS-OTP-REQ): requesting SMS-OTP verification..") | |||||
jwe_req = self.jwe_handler.req_jwe_bearer(url, 'login', data, | |||||
auth=True) | |||||
ans = self._send_req(jwe_req) | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
if 'X-TEL' not in ans.headers: | |||||
raise RequestFailure(f"Malformed request, missing 'X-TEL'.", ans) | |||||
logger.debug("Enrol(SMS-OTP-REQ): SMS-OTP verification requested.") | |||||
tel = ans.headers.get('X-TEL') | |||||
logger.debug(f"Enrol(SMS-OTP-REQ): SMS-OTP sent to ***{tel}.") | |||||
return tel | |||||
def _enrol_stage_send_sms_otp(self, otp): | |||||
url = urljoin(self.LOGIN_URL, "v4/xmobileauthjwt") | |||||
data = {} | |||||
data['otp'] = str(otp) | |||||
data['authLevel'] = "2" | |||||
data['nonce'] = str(uuid4()) | |||||
logger.debug("Enrol(SMS-OTP-AUTH): authenticating SMS-OTP.") | |||||
jwe_req = self.jwe_handler.req_jwe_bearer(url, 'login', data, | |||||
auth=True) | |||||
ans = self._send_req(jwe_req) | |||||
if ans.status_code == 401: | |||||
raise AuthError("Wrong SMS-OTP code!") | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
if 'X-RESULT' not in ans.headers: | |||||
raise RequestFailure( | |||||
f"Malformed request, missing 'X-RESULT'.", ans) | |||||
logger.debug("Enrol(SMS-OTP-AUTH): decrypting response.") | |||||
ans_json = self.jwe_handler.decrypt(ans.headers.get('X-RESULT')) | |||||
logger.debug(f"Enrol(SMS-OTP-AUTH): decrypted response: {ans_json}") | |||||
if 'data' not in ans_json: | |||||
raise RequestFailure("Malformed request, missing 'data'.", ans) | |||||
if 'token' not in ans_json['data']: | |||||
raise RequestFailure("Malformed request, missing 'token'.", ans) | |||||
reg_token = ans_json['data']['token'] | |||||
logger.debug(f"Enrol(SMS-OTP-AUTH): got reg token: {reg_token}.") | |||||
self.reg_token = reg_token | |||||
def _enrol_stage_finalize(self, userpin=""): | |||||
url = urljoin(self.SH_URL, "v1/registerApp") | |||||
data = {} | |||||
data['userPIN'] = userpin | |||||
data['idpAccessToken'] = "" | |||||
data['registerToken'] = self.reg_token | |||||
logger.debug("Enrol(REG-APP): encrypting request.") | |||||
print(data) | |||||
jwe_req = self.jwe_handler.req_jwe_post( | |||||
url, 'registerApp', data, auth=True) | |||||
logger.debug("Enrol(REG-APP): sending request.") | |||||
ans = self._send_req(jwe_req) | |||||
try: | |||||
ans_jwe = self._parse_jwe_response(ans) | |||||
except RequestFailure as e: | |||||
if e.error_code == "PIN-ERR-1": | |||||
raise NotInitializedPIN() | |||||
raise e | |||||
ans_json = self.jwe_handler.decrypt(ans_jwe) | |||||
if 'data' not in ans_json: | |||||
raise RequestFailure("Malformed request, missing 'data'.", ans) | |||||
if 'appRegisterID' not in ans_json['data']: | |||||
raise RequestFailure( | |||||
"Malformed request, missing 'appRegisterID'.", ans) | |||||
if 'secretAPP' not in ans_json['data']: | |||||
raise RequestFailure( | |||||
"Malformed request, missing 'secretAPP'.", ans) | |||||
self.appdata['sca_app_regid'] = ans_json['data']['appRegisterID'] | |||||
self.appdata['sca_seed'] = ans_json['data']['secretAPP'] | |||||
logger.debug("Enrol(REG-APP): completed successfully!") | |||||
def check_register(self): | |||||
url = urljoin(self.SH_URL, "v1/checkRegisterApp") | |||||
data = {'appRegisterID': self.appdata['sca_app_regid']} | |||||
jwe_req = self.jwe_handler.req_jwe_post(url, "checkRegisterApp", data, | |||||
auth=True) | |||||
ans = self._send_req(jwe_req) | |||||
result = self._parse_jwe_response(ans) | |||||
if 'valid' not in result: | |||||
raise RequestFailure( | |||||
"Malformed request, missing 'valid'.", ans) | |||||
return result["valid"] | |||||
def enrol_sms_start(self, username, password): | |||||
self._enrol_stage_basiclogin(username, password) | |||||
tel = self._enrol_stage_req_sms_otp(username) | |||||
return tel | |||||
def enrol_sms_finish(self, otp, force_userpin=None): | |||||
self._enrol_stage_send_sms_otp(otp) | |||||
try: | |||||
self._enrol_stage_finalize() | |||||
except NotInitializedPIN as e: | |||||
if force_userpin: | |||||
logger.info('Enrol(REG-APP): Setting the new provided PIN.') | |||||
self._enrol_stage_finalize(force_userpin) | |||||
return | |||||
raise e | |||||
def _pin_login(self, userpin): | |||||
url_challenge = urljoin(self.LOGIN_URL, | |||||
"secureholder/v4/native/challenge") | |||||
url_authorize = urljoin(self.LOGIN_URL, | |||||
"secureholder/v4/native/az") | |||||
logger.debug("PINLogin: acquiring v4 challenge.") | |||||
req = self.jwe_handler.req_jwe_post(url_challenge, "login", {}, | |||||
auth=True) | |||||
ans = self._send_req(req) | |||||
ans = self._parse_v4(ans) | |||||
logger.debug(f"PINLogin: got v4 challenge: {ans}.") | |||||
logger.debug(f"PINLogin: preparing challenge response.") | |||||
data = {} | |||||
data['signature'] = self._sign_challenge_v4(ans, userpin) | |||||
data['appRegisterID'] = self.appdata['sca_app_regid'] | |||||
logger.debug(f"PINLogin: sending response {data}.") | |||||
req = self.jwe_handler.req_jwe_post(url_authorize, "login", data, | |||||
auth=True) | |||||
ans = self._send_req(req) | |||||
try: | |||||
ans = self._parse_v4(ans) | |||||
except RequestFailure as e: | |||||
if e.error_code == "PIN-ERR-3": | |||||
raise AuthError("Codice PosteID errato!") | |||||
elif e.error_code == "CERT-ERR-2": | |||||
raise AuthError("Codice PosteID bloccato per troppi errori!") | |||||
raise(e) | |||||
self.profile_token = ans['profile_token'] | |||||
self.access_token = ans['access_token'] | |||||
logger.debug(f"PINLogin: logged in, session token aquired.") | |||||
def _unenrol(self): | |||||
if not self.access_token: | |||||
raise Exception( | |||||
'Need to acquire an access token (pin_login) before.') | |||||
logger.debug("Unenrol: unenrolling device.") | |||||
url = urljoin(self.LOGIN_URL, "secureholder/v4/native/delete-posteid") | |||||
jwe_req = self.jwe_handler.req_jwe_bearer(url, "delete_posteid", {}, | |||||
auth=True) | |||||
jwe_req.headers['Authorization'] = "Bearer " + self.access_token | |||||
ans = self._send_req(jwe_req) | |||||
ans = self._parse_v4(ans) | |||||
logger.debug("Unenrol: device unenrolled.") | |||||
def list_txs(self): | |||||
if not self.access_token: | |||||
raise Exception( | |||||
"Need to acquire an access token (pin_login) before.") | |||||
url = urljoin(self.LOGIN_URL, | |||||
"secureholder/v4/native/list-transaction") | |||||
jwe_req = self.jwe_handler.req_jwe_post(url, "login", {}, auth=True) | |||||
jwe_req.headers['Authorization'] = "Bearer " + self.access_token | |||||
ans = self._send_req(jwe_req) | |||||
ans_json = self._parse_v4(ans) | |||||
if 'transaction' not in ans_json: | |||||
raise RequestFailure( | |||||
f"Malformed response, missing 'transaction'.", ans) | |||||
return ans_json['transaction'] | |||||
def authorize_tx_start(self, tx_id): | |||||
data = {} | |||||
data['jti'] = tx_id | |||||
data['appRegisterID']: self.appdata['sca_app_regid'] | |||||
url = urljoin(self.AUTH_URL, "secureholder/v4/challenge") | |||||
jwe_req = self.jwe_handler.req_jwe_post(url, "login", data, auth=True) | |||||
ans = self._send_req(jwe_req) | |||||
ans_json = self._parse_v4(ans) | |||||
if ans_json['status'] != "v4_pending": | |||||
raise Exception( | |||||
f"TX Status is '{ans_json['status']}', not pending.") | |||||
if 'jti' not in ans_json: | |||||
raise RequestFailure(f"Malformed response, missing 'jti'.", ans) | |||||
if 'randK' not in ans_json: | |||||
raise RequestFailure(f"Malformed response, missing 'randK'.", ans) | |||||
if 'transaction-challenge' not in ans_json: | |||||
raise RequestFailure( | |||||
f"Malformed response, missing 'transaction-challenge'.", ans) | |||||
return ans_json | |||||
def authorize_tx_finish(self, challenge, userpin): | |||||
signature = self._sign_challenge_v4(challenge, userpin) | |||||
data = {} | |||||
data['signature'] = signature | |||||
data['appRegisterID'] = self.appdata['sca_app_regid'] | |||||
url = urljoin(self.AUTH_URL, "secureholder/v4/az") | |||||
jwe_req = self.jwe_handler.req_jwe_post(url, "login", data, auth=True) | |||||
ans = self._send_req(jwe_req) | |||||
try: | |||||
ans_json = self._parse_v4(ans) | |||||
except RequestFailure as e: | |||||
if e.error_code == "PIN-ERR-3": | |||||
raise AuthError("Codice PosteID errato!") | |||||
elif e.error_code == "CERT-ERR-2": | |||||
raise AuthError("Codice PosteID bloccato per troppi errori!") | |||||
raise(e) | |||||
if ans_json['status'] != "v4_signed": | |||||
raise Exception( | |||||
f"TX Status is '{ans_json['status']}', not signed.") | |||||
def unenrol(self, userpin): | |||||
self._pin_login(userpin) | |||||
self._unenrol() | |||||
@property | |||||
def totp(self): | |||||
seed = self.appdata['sca_seed'] | |||||
seed = b32encode(seed.encode("utf-8") + b'\0' * 32) | |||||
seed = seed.decode("utf-8").replace('=', '') | |||||
totp = TOTP(seed, interval=120) | |||||
return totp |
@ -0,0 +1,25 @@ | |||||
import hmac | |||||
import json | |||||
from base64 import b64encode, urlsafe_b64encode | |||||
from hashlib import sha256 | |||||
def sha256_base64(payload): | |||||
out = payload.encode('utf-8') | |||||
out = sha256(out).digest() | |||||
out = b64encode(out) | |||||
return out.decode('utf-8') | |||||
def hmac_sha256(key, message): | |||||
hm = hmac.new(key.encode('utf-8'), digestmod=sha256) | |||||
hm.update(message.encode('utf-8')) | |||||
digest = hm.digest() | |||||
return urlsafe_b64encode(digest).decode('utf-8').replace('=', '') | |||||
class RequestFailure(Exception): | |||||
def __init__(self, message, ans, error_code=None): | |||||
super().__init__(message) | |||||
self.ans = ans | |||||
self.error_code = error_code |
@ -0,0 +1,5 @@ | |||||
# coding: utf-8 | |||||
# file generated by setuptools_scm | |||||
# don't change, don't track in version control | |||||
__version__ = version = '0.1.dev0' | |||||
__version_tuple__ = version_tuple = (0, 1, 'dev0') |
@ -0,0 +1,126 @@ | |||||
import logging | |||||
from urllib.parse import urljoin | |||||
from uuid import uuid4 | |||||
import requests | |||||
from .appdata import AppData | |||||
from .jwe_handler import JWEHandler | |||||
from .utils import sha256_base64, RequestFailure | |||||
logger = logging.getLogger(__name__) | |||||
class XKey: | |||||
REGISTRY_URL = ("https://appregistry-posteid.mobile.poste.it" | |||||
"/jod-app-registry/") | |||||
APP_NAME = "app-posteid-v3" | |||||
ACTIVITY_ID = "C6050AC80E8B5288A01237" | |||||
DEVICE_SPECS = ("Android", "11", | |||||
"sdk_gphone_x86_64_arm64", "4.5.204", | |||||
"true") | |||||
def __init__(self, profile_name): | |||||
self.appdata = AppData(profile_name) | |||||
self.s = requests.Session() | |||||
self.s.headers = {'Accept-Encoding': "gzip", | |||||
'User-Agent': "okhttp/3.12.1", | |||||
'Connection': "keep-alive"} | |||||
self.jwe_handler = JWEHandler(self.appdata) | |||||
def _send_req(self, request): | |||||
prepped = self.s.prepare_request(request) | |||||
return self.s.send(prepped) | |||||
def _register_stage_init(self, register_nonce): | |||||
url = urljoin(self.REGISTRY_URL, "v2/registerInit") | |||||
headers = {'Content-Type': "application/json; charset=UTF-8"} | |||||
data = {} | |||||
data['appName'] = "app-posteid-v3" | |||||
data['initCodeChallenge'] = sha256_base64(register_nonce) | |||||
logger.debug(f"Registration(INIT): sending challenge: {data}") | |||||
ans = self.s.post(url, headers=headers, json=data) | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
if not ans.headers.get('Content-Type').startswith("application/json"): | |||||
raise RequestFailure("Response not JSON.", ans) | |||||
ans_json = ans.json() | |||||
if 'pubServerKey' not in ans_json: | |||||
raise RequetFailure("Response does not contain 'pubServerKey'", | |||||
ans) | |||||
pubkey = ans_json['pubServerKey'] | |||||
self.appdata.serv_pubkey = pubkey | |||||
logger.debug(f"Registration(INIT): got server pubkey: {pubkey}.") | |||||
def _register_stage_register(self, register_nonce): | |||||
url = urljoin(self.REGISTRY_URL, "v2/register") | |||||
data = {} | |||||
data['initCodeVerifier'] = register_nonce | |||||
data['xdevice'] = self.ACTIVITY_ID + "::" + ":".join(self.DEVICE_SPECS) | |||||
data['pubAppKey'] = self.appdata.app_privkey.pubkey_b64 | |||||
logger.debug("Registration(REG): encrypting app data.") | |||||
jwe_req = self.jwe_handler.req_jwe_post(url, "register", data) | |||||
logger.debug("Registration(REG): sending app data.") | |||||
ans = self._send_req(jwe_req) | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
logger.debug("Registration(REG): decrypting response.") | |||||
ans_json = self.jwe_handler.decrypt(ans.content) | |||||
logger.debug(f"Registration(REG): decrypted response: {ans_json}") | |||||
if 'data' not in ans_json: | |||||
raise RequestFailure("Malformed request, missing 'data'.", ans) | |||||
if 'app-uuid' not in ans_json['data']: | |||||
raise RequestFailure("Malformed request, missing 'app-uuid'.", ans) | |||||
if 'otpSecretKey' not in ans_json['data']: | |||||
raise RequestFailure("Malformed request, missing 'otpSecretKey'.", | |||||
ans) | |||||
self.appdata['xkey_appuuid'] = ans_json['data']['app-uuid'] | |||||
self.appdata['xkey_seed'] = ans_json['data']['otpSecretKey'] | |||||
def _register_stage_activate(self): | |||||
url = urljoin(self.REGISTRY_URL, "v2/activation") | |||||
logger.debug("Registration(ACTIVATE): encrypting request.") | |||||
jwe_req = self.jwe_handler.req_jwe_post(url, "activation", {}, | |||||
auth=True) | |||||
logger.debug("Registration(ACTIVATE): sending request.") | |||||
ans = self._send_req(jwe_req) | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
self.appdata['activated'] = True | |||||
logger.debug("Registration(ACTIVATE): activated.") | |||||
def _register_stage_update(self, register_nonce): | |||||
url = urljoin(self.REGISTRY_URL, "v2/register") | |||||
data = {} | |||||
data['initCodeVerifier'] = register_nonce | |||||
data['xdevice'] = self.ACTIVITY_ID + "::" + ":".join(self.DEVICE_SPECS) | |||||
data['pubAppKey'] = self.appdata.app_privkey.pubkey_b64 | |||||
logger.debug("Registration(UPDATE): encrypting app data.") | |||||
jwe_req = self.jwe_handler.req_jwe_post(url, "registerUpdate", data, | |||||
auth=True) | |||||
logger.debug("Registration(UPDATE): sending app data.") | |||||
ans = self._send_req(jwe_req) | |||||
if ans.status_code != 200: | |||||
raise RequestFailure(f"Wrong status code: {ans.status_code}", ans) | |||||
logger.debug("Registration(UPDATE): decrypting response.") | |||||
ans_json = self.jwe_handler.decrypt(ans.content) | |||||
logger.debug(f"Registration(UPDATE): decrypted response: {ans_json}") | |||||
if 'data' not in ans_json: | |||||
raise RequestFailure("Malformed request, missing 'data'.", ans) | |||||
if 'app-uuid' not in ans_json['data']: | |||||
raise RequestFailure("Malformed request, missing 'app-uuid'.", ans) | |||||
if 'otpSecretKey' not in ans_json['data']: | |||||
raise RequestFailure("Malformed request, missing 'otpSecretKey'.", | |||||
ans) | |||||
self.appdata['xkey_appuuid'] = ans_json['data']['app-uuid'] | |||||
self.appdata['xkey_seed'] = ans_json['data']['otpSecretKey'] | |||||
def register_xkey(self): | |||||
register_nonce = str(uuid4()) | |||||
logger.debug(f"Starting registration (nonce: {register_nonce}).") | |||||
self._register_stage_init(register_nonce) | |||||
if self.appdata['xkey_seed']: | |||||
self._register_stage_update(register_nonce) | |||||
else: | |||||
self._register_stage_register(register_nonce) | |||||
self._register_stage_activate() |
@ -0,0 +1,10 @@ | |||||
[build-system] | |||||
requires = [ | |||||
"setuptools>=42", | |||||
"wheel", | |||||
"setuptools_scm[toml]>3.4", | |||||
] | |||||
build-backend = "setuptools.build_meta" | |||||
[tool.setuptools_scm] | |||||
write_to = "pyjod/version.py" |
@ -0,0 +1,38 @@ | |||||
[metadata] | |||||
name = pyjod | |||||
version = attr:pyjod.version | |||||
description = Python implementation of PosteID. | |||||
long_description = file:README.md | |||||
long_description_content_type = text/markdown | |||||
author = Vendetta | |||||
author_email = aaron@guerrilla.open | |||||
classifiers = | |||||
Development Status :: 1 - Planning | |||||
Programming Language :: Python | |||||
Programming Language :: Python :: 3.9 | |||||
Programming Language :: Python :: 3.10 | |||||
platforms = any | |||||
[options] | |||||
packages = | |||||
pyjod | |||||
install_requires = | |||||
xdgappdirs>=1.4 | |||||
cryptography>=3.3 | |||||
jwcrypto>=1.3 | |||||
pathvalidate>=2.5 | |||||
pyotp>=2.6 | |||||
requests | |||||
setup_requires = | |||||
setuptools_scm | |||||
[options.extras_require] | |||||
qr = | |||||
numpy | |||||
qrcodeT | |||||
[options.entry_points] | |||||
console_scripts = | |||||
posteid = pyjod.cli:run |