Kako se (spet) znebiti NLB avtentikatorja

spisal 24. 4. 2026
glej tudi: https://sijanec.eu/nlb.shtml

Med prijavo v spletno banko NLB Klik sem opazil, da prijava z enkratnim geslom ni več mogoča. 2026 je torej leto, ko bodo uporabniki NLB Klika prisiljeni v uporabo mobilnih telefonov Android ali iOS, s čimer se bo dodatno utrdil monopol Googla in Appla. Telefon ne sme biti odklenjen (rooted), mora pa imeti dostop do interneta in funkcionalno zadnjo kamero. V tej objavi predstavim način prijavljanja v NLB Klik na svoboden način.

Sam sicer uporabljam NLB Klik avtentikator, saj alternativne opcije že od petega maja 2021 ni, vendar sem vseeno proti uporabi le-tega. Po nekaj pokukih v NLB Klik aplikacijo sem ugotovil, da za avtorizacijo prijave s QR kodo uporablja odprtokoden avtorizacijski sistem podjetja Wultra, imenovan PowerAuth, ki ga je verjetno NLB od omenjenega podjetja naročila. Ta algoritem ima uradno odprtokodno implementacijo, a v zaledju registracija še vedno temelji na starem skritem algoritmu za enkratne kode, ki nima uradne odprtokodne implementacije. NLB Klika ne boste spravili na Android telefon, če le-ta nima Googlovih storitev.

Ker je GitHub znan po izbrisih programov, je varnostna kopija dostopna tukaj.

Za implementacijo te metode ne boste potrebovali NLB Klik aplikacije ali Googlovih storitev.

Uporaba

Uporaba je enostavna kot 0-1-2-3.

Ničti korak

Na računalniku potrebujemo sledeče programe:

Telefon ni potreben.

Prvi korak: pridobitev aktivacijske kode in serijske številke

Potrebovali bomo serijsko številko (dobimo jo na email) in aktivacijsko kodo (na sms), za kateri zaprosimo pri kontaktnem centru 24/7 na tel. št. 01 477 2000.

Če smo v preteklosti shranili OTP generator in se spomnimo serijske številke (oz. jo laho izbrskamo iz e-pošte), potem ne potrebujemo novih kod.

Aktivnih imamo lahko več instanc klika naenkrat, torej z aktivacijo na računalniku ne boste pokvarili aplikacije na telefonu. Hkrati to pomeni, da če nepridiprav dobi v roke naše kode, tega ne bomo nikoli izvedeli. V to, ali je možno posamezne kode deaktivirati, se nisem poglabljal.

Drugi korak: registracija

S to skripto izvedemo registracijo aktivacijskih kod. Poženemo jo samo enkrat. Ustvarila nam bo statičen config.json in skrivni pa_token.txt ter pa_status.json, s katerimi lahko avtoriziramo dostop komurkoli. S temi datotekami ravnajmo pazljivo.

Med delovanjem opcijsko ustvarja tudi nekatere druge datoteke, da lahko nadaljuje, v primeru, da je šlo v prvo kaj narobe.
register.py
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.14"
# dependencies = [
#     "oath>=1.4.4",
# ]
# ///

import argparse
import base64
import hashlib
import hmac
import json
import random
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.error import HTTPError

from oath import totp

# Datoteke za powerauth-java-cmd
STATUS_FILE = Path("./pa_status.json")
CONFIG_FILE = Path("./config.json")

# Vmesne datoteke (lahko so /dev/null, če jih ne želimo shranjevati)
OTP_REGISTERED_FLAG = Path("./otp_registered.flag")
ACTIVATION_FILE = Path("./pa_activation_code.txt")
OTP_SEED_FILE = Path("./otp_seed.txt")
PA_ACTIVATED_FLAG = Path("./pa_activated.flag")

# Izhodna datoteka
TOKEN_FILE = Path("./pa_token.txt")

# https://github.com/wultra/powerauth-cmd-tool/releases/download/1.10.1/powerauth-java-cmd.jar
POWERAUTH_JAR = Path("./powerauth-java-cmd.jar")


def http_post(
    url: str, data: dict, is_json: bool, headers: Optional[dict] = None
) -> str:
    content_type = (
        "application/json; charset=UTF-8"
        if is_json
        else "application/x-www-form-urlencoded"
    )
    headers = (headers or {}).copy()
    headers["Content-Type"] = content_type

    body = json.dumps(data) if is_json else urlencode(data)

    request = Request(url, data=body.encode("utf-8"), headers=headers, method="POST")
    print(request.method, request.full_url, "with body:", body)
    try:
        with urlopen(request) as response:
            return response.read().decode("utf-8")
    except HTTPError as e:
        raise RuntimeError(
            f"HTTP error {e.code}: {e.reason}\n{e.headers}\n{e.read().decode('utf-8')}"
        ) from e


def generate_config():
    # Hardcoded in the apk, same for everyone.
    config = (
        "ARCCQ47aWyZ5ryvwWQaYh2GkEF3PL/weCkvypJTVH+Tl+nkBAUEEFi4EhkGausAAnHPSVG"
        "qiya+PlMq4eZRHn5yQHIjJHs0X3BmXx5GnFIgS7t8kmj5+3ELb31WntQEzcoyUOmmDyQ=="
    )
    text = json.dumps({"applicationName": "NLB Klik", "mobileSdkConfig": config})
    CONFIG_FILE.write_text(text + "\n", encoding="utf-8")


def require_json_field(payload: str, field: str, error_message: str) -> str:
    try:
        data = json.loads(payload)
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"Neveljaven JSON odgovor: {payload}") from exc
    value = data.get(field)
    if not value:
        print(
            f"Nepričakovan JSON odgovor; ni polja '{field}': {payload}", file=sys.stderr
        )
        raise RuntimeError(error_message)
    return value


def run_powerauth_command(*args: str) -> str:
    command = [
        "java",
        "-jar",
        str(POWERAUTH_JAR),
        "--url",
        "https://nlb-si-mtoken.wultra.app/enrollment-server",
        *args,
    ]
    try:
        result = subprocess.run(
            command,
            check=True,
            capture_output=True,
            text=True,
        )
    except FileNotFoundError as exc:
        raise RuntimeError("Ukaz java ni na voljo") from exc
    except subprocess.CalledProcessError as exc:
        stderr = exc.stderr.strip()
        raise RuntimeError(stderr or "PowerAuth ukaz ni uspel") from exc
    return result.stdout


def parse_token_fields(payload: str) -> tuple[str, str]:
    """Poberi tokenId in tokenSecret iz PowerAuthovega izhoda."""
    token_id_match = re.search(r'"tokenId"\s*:\s*"([^"]+)"', payload)
    token_secret_match = re.search(r'"tokenSecret"\s*:\s*"([^"]+)"', payload)

    if not token_id_match or not token_secret_match:
        print("Nepričakovan izhod PowerAuth ukaza:", payload, file=sys.stderr)
        raise RuntimeError("Nisem dobil tokenId in tokenSecret :(")

    return token_id_match.group(1), token_secret_match.group(1)


def luhn_digit(length: int, value: int) -> int:
    """Izračunaj Luhnovo kontrolno števko."""
    total = 0
    for i in range(length):
        digit = value % 10
        value //= 10
        if i % 2 == 0:
            digit = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9][digit]
        total += digit
    mod = total % 10
    return 0 if mod == 0 else 10 - mod


def validate_activation(code: str) -> bool:
    """Preveri, ali je aktivacijska koda veljavna glede na Luhnovo formulo."""
    return len(code) == 16 and luhn_digit(15, int(code[:-1])) == int(code[-1])


def derive_regcode(
    activation_bytes: bytes, serial_bytes: bytes, rng_bytes: Optional[bytes]
) -> str:
    """Izračunaj registracijsko kodo za OTP generator."""
    rng_bytes = rng_bytes or random.randbytes(2)

    password = activation_bytes + rng_bytes

    derived_key = hashlib.pbkdf2_hmac("sha256", password, serial_bytes, 8, 16)
    digest = hmac.new(derived_key, serial_bytes, hashlib.sha256).digest()
    derived13 = ((digest[-2] & 0x1F) << 8) | digest[-1]

    value = (derived13 << 16) | int.from_bytes(rng_bytes, "big")

    code = f"{value}{luhn_digit(9, value)}".zfill(10)
    return code[:5] + "-" + code[5:]


def main() -> int:
    parser = argparse.ArgumentParser(description="Registrira NLB Klik avtorizator")
    parser.add_argument("serial", help="serijska številka (iz emaila)")
    parser.add_argument("activation", help="aktivacijska koda (iz smsa)")
    parser.add_argument("rng_hex", nargs="?", help="2 naključna bajta, npr. '1337'")
    args = parser.parse_args()

    serial = args.serial.replace("-", "").strip()
    activation = args.activation.replace("-", "").strip()

    if not OTP_SEED_FILE.exists():
        # lahko nastavimo po želji, npr. rng_hex = "1337"
        rng_hex = args.rng_hex or random.randbytes(2).hex()
        assert re.fullmatch(r"[0-9a-fA-F]{4}", rng_hex), "neveljavn rng_hex"

        serial_bytes = serial.encode("utf-8")
        assert validate_activation(activation), "Neveljavna aktivacijska koda"
        activation_bytes = int(activation[:-1]).to_bytes(7, "big")
        rng_bytes = bytes.fromhex(rng_hex)

        regcode = derive_regcode(activation_bytes, serial_bytes, rng_bytes)

        key = hashlib.pbkdf2_hmac(
            hash_name="sha256",
            password=activation_bytes + rng_bytes,
            salt=serial_bytes,
            iterations=8,
            dklen=16,
        )
        print("OTP seme:", key.hex())
        print("Registracijska koda:", regcode)
        OTP_SEED_FILE.write_text(key.hex() + "\n" + regcode, encoding="utf-8")
    else:
        print(f"{OTP_SEED_FILE} že obstaja.")
        key_hex, regcode = OTP_SEED_FILE.read_text(encoding="utf-8").strip().split("\n")
        key = bytes.fromhex(key_hex)

    otp = totp(key.hex(), hash=hashlib.sha256, t=int(time.time()), format="dec8")

    if not OTP_REGISTERED_FLAG.exists():
        # stupid identifiers
        deviceid = str(random.randint(0, 2**32 - 1))
        instanceid = base64.b64encode(random.randbytes(8))
        http_post(
            "https://aam.nlb.si/igst/txnpoll",
            {
                "apiversion": "6",
                "platform": "ANDROID",
                "supportsoffline": "1",
                "serialnumber": serial,
                "deviceid": deviceid,
                "supportstransactions": "1",
                "cmd": "enroll",
                "instanceid": instanceid,
                "version": "9.1.1",
                "notifyenabled": "0",
                "supportsonline": "1",
                "type": "TOKEN",
                "appid": "co.infinum.nlb",
                "appscheme": "etrust",
                "otp": str(otp),
                "regcode": regcode,
            },
            is_json=False,
        )
        OTP_REGISTERED_FLAG.write_text(
            f"SERIAL={serial}\nACTIVATION={activation}\nREGCODE={regcode}\nDEVICEID={deviceid}\nINSTANCEID={instanceid.decode()}\n",
            encoding="utf-8",
        )
        print("OTP generator registriran.")
    else:
        print(f"{OTP_REGISTERED_FLAG} že obstaja.")

    if not CONFIG_FILE.exists():
        print(f"Generiram {CONFIG_FILE} ...")
        generate_config()
    else:
        print(f"{CONFIG_FILE} že obstaja.")

    if not ACTIVATION_FILE.exists():
        print("Pridobivam OIDC žeton ...")
        resp = http_post(
            "https://klik.nlb.si/authentication/realms/retail/protocol/openid-connect/token",
            {
                "client_id": "klik-device-registration",
                "grant_type": "password",
                "username": str(serial),
                "password": str(otp),
            },
            is_json=False,
        )
        oidc = require_json_field(resp, "access_token", "Ni OIDC žetona :(")

        print("Registriram Wultra uporabnika ...")
        resp = http_post(
            "https://klik.nlb.si/authentication/realms/retail/wultra-auth/register-wultra-user",
            {"appId": "Klik"},
            headers={"Authorization": f"Bearer {oidc}"},
            is_json=True,
        )
        pa_activ = require_json_field(resp, "activationCode", "Ni aktivacijske kode :(")
        ACTIVATION_FILE.write_text(f"{pa_activ}\n", encoding="utf-8")
        print("PA registracija uspela.")
    else:
        print(f"{ACTIVATION_FILE} že obstaja.")
        pa_activ = ACTIVATION_FILE.read_text(encoding="utf-8").strip()

    if not PA_ACTIVATED_FLAG.exists():
        print("Aktiviram PowerAuth ...")
        resp = run_powerauth_command(
            "--status-file",
            str(STATUS_FILE),
            "--config-file",
            str(CONFIG_FILE),
            "--method",
            "create",
            "--version",
            "3.3",
            "--password",
            "1234",
            "--algorithm",
            "EC_P384_ML_L5",
            "--activation-code",
            pa_activ,
        )
        PA_ACTIVATED_FLAG.write_text(resp, encoding="utf-8")
        print("PowerAuth aktiviran.")
    else:
        print(f"{PA_ACTIVATED_FLAG} že obstaja. PowerAuth je verjetno že aktiviran.")

    if not TOKEN_FILE.exists():
        print("Generiram avtorizacijski zeton ...")
        token_response = run_powerauth_command(
            "--status-file",
            str(STATUS_FILE),
            "--config-file",
            str(CONFIG_FILE),
            "--method",
            "create-token",
            "--version",
            "3.3",
            "--password",
            "1234",
            "--auth-code-type",
            "possession_knowledge",
        )
        token_id, token_secret = parse_token_fields(token_response)
        TOKEN_FILE.write_text(
            f"TOKEN_ID={token_id}\nTOKEN_SECRET={token_secret}\n",
            encoding="utf-8",
        )
        print(f"Žeton shranjen v {TOKEN_FILE}")
    else:
        print(f"{TOKEN_FILE} že obstaja. Pojdi na pivo")


if __name__ == "__main__":
    raise SystemExit(main())

Tretji korak: avtorizacija seje

S to skripto avtoriziramo posamezno sejo. Poženemo jo vsakič, ko se želimo prijaviti v NLB Klik. Predpogoj za delovanje je prisotnost ./config.json in datotek s stanjem pa_status.json in pa_token.txt, ki jih dobimo s prejšnjo skripto.
login.py
#!/bin/bash
set -euo pipefail

STATUS_FILE="./pa_status.json"
CONFIG_FILE="./config.json"
TOKEN_FILE="./pa_token.txt"

source "$TOKEN_FILE"
#echo "Token ID: $TOKEN_ID"
#echo "Token Secret: $TOKEN_SECRET"

IP_ADDR=$(curl -sS --fail https://api64.ipify.org)
TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
echo "Time: $TIME"
echo "IP Address: $IP_ADDR"

read -p "Vnesi avtorizacijski URL: " URL
OPERATION_ID=$(echo "$URL" | sed -n 's/.*[?&]operationId=\([^&]*\).*/\1/p')
OTP=$(echo "$URL" | sed -n 's/.*[?&]proximityOtp=\([^&]*\).*/\1/p')

cat <<EOT > request.json
{
    "requestObject": {
        "id": "$OPERATION_ID"
    }
}
EOT
java -jar powerauth-java-cmd.jar \
    --url "https://nlb-si-mtoken.wultra.app/enrollment-server/api/auth/token/app/operation/detail/claim" \
    --status-file "$STATUS_FILE" \
    --config-file "$CONFIG_FILE" \
    --method "validate-token" \
    --http-method "POST" \
    --version "3.3" \
    --data-file "request.json" \
    --token-id "$TOKEN_ID" \
    --token-secret "$TOKEN_SECRET" 2>/dev/null


cat <<EOT > request.json
{
    "requestObject": {
        "data": "A2*Tspletna banka NLB Klik*T$IP_ADDR*T",
        "id": "$OPERATION_ID",
        "proximityCheck": {
            "otp": "$OTP",
            "timestampReceived": "$TIME",
            "timestampSent": "$TIME",
            "type": "DEEPLINK"
        }
    }
}
EOT
java -jar powerauth-java-cmd.jar \
    --url "https://nlb-si-mtoken.wultra.app/enrollment-server/api/auth/token/app/operation/authorize" \
    --status-file "$STATUS_FILE" \
    --config-file "$CONFIG_FILE" \
    --method "authenticate" \
    --http-method "POST" \
    --version "3.3" \
    --resource-id "/operation/authorize" \
    --auth-code-type "possession_knowledge" \
    --data-file "request.json" \
    --password "1234" 2>/dev/null

Tehnični pregled

Shema poteka avtentikacije

Stari generator OTP kod

Izhodišče za raziskavo je analiza Stephena Shkardoona.

Entrustov algoritem sestavi seme za otp generator iz serijske številke in aktivacijske kode in 2 naključnih bajtov, ki se generirata na napravi. Naključna bajta se v obliki registracijske kode pošljeta na strežnik, da bo le-ta lahko validiral naše žetone.

Ideja je, da napadalec, ki prestreže serijsko številko in aktivacijsko kodo, ne more ukrasti našega generatorja enkratnih gesel, ker ne ve registracijske kode. Če nam uspe, da smo prvi opravili registracijo, postaneta serijska številka in aktivacijska koda neuporabni.

Stephen nam predstavi algoritem, s katerim lahko iz veljavne trojice kod pridobimo seme za generator, ne vemo pa kako generirati veljavne registracijske kode.

Na pomoč nam priskoči veliki jezikovni model, ki pregleda neberljivo vzvratno prevedeno kodo iz Klik apkja in jo prevede v Python.

derive_regcode.py
def derive_regcode(activation_bytes: bytes, serial_bytes: bytes):
    """Izračunaj registracijsko kodo za OTP generator."""
    rng_bytes = random.randbytes(2)

    password = activation_bytes + rng_bytes

    derived_key = hashlib.pbkdf2_hmac("sha256", password, serial_bytes, 8, 16)
    digest = hmac.new(derived_key, serial_bytes, hashlib.sha256).digest()
    derived13 = ((digest[-2] & 0x1F) << 8) | digest[-1]

    value = (derived13 << 16) | int.from_bytes(rng_bytes, "big")

    code = f"{value}{luhn_digit(9, value)}".zfill(10)
    return code[:5] + "-" + code[5:]

Sedaj lahko svojo registracijsko kodo pošljemo na aam.nlb.si in jo uspešno validiramo.

POST https://aam.nlb.si/igst/txnpoll HTTP/2.0
X-INSTANA-ANDROID: UUIDv4, ni pomembno
Content-Type: application/x-www-form-urlencoded
User-Agent: Dalvik/2.1.0 (podatki o sistemu, build number ipd)
Connection: Keep-Alive
apiversion=6
platform=ANDROID
supportsoffline=1
serialnumber=serijska številka
deviceid=9-mestno število
supportstransactions=1
cmd=enroll
instanceid=base64
version=9.1.1
notifyenabled=0
supportsonline=1
type=TOKEN
appid=co.infinum.nlb
appscheme=etrust
otp=trenutni TOTP
regcode=registracijska koda; dvakrat po 5 števk, ločenih s minusom
Odgovor
{
  "apiversion": "6",
  "status": "OK"
}

OIDC in PowerAuth

Ključno vlogo igra orodje mitmproxy. Z njim lahko beremo dešifrirane HTTPS zahtevke aplikacije Klik in odkrijemo delovanje APIja, ki ga bomo nato simulirali z računalnikom.

Če ignoriramo glave, izgleda naloga precej preprosta. Na žalost pa tak zahtevek sam po sebi ni veljaven; potrebujemo glavo x-powerauth-authorization.

Ker je PowerAuth za namizne računalnike že implementiran, bo najlažje, da preprosto sledimo celotnemu postopku registracije. Za uporabo uradnega CLI orodja potrebujemo datoteko s konfiguracijo in datoteko stanja. Slednja se ustvari (in nadalje upravlja) avtomatsko, ko opravimo API klic za registracijo.

MobileSdkConfig je trdokodiran v Klik aplikaciji (sources/si/nlb/klik/retail/KlikRetailApplication.java) in je za vse uporabnike enak. (Kako dobim apk?). ApplicationName preberemo iz resources/res/values/strings.xml. Sestavimo konfiguracijsko datoteko:

{
  "applicationName": "NLB Klik",
  "mobileSdkConfig": "ARCCQ47aWyZ5ryvwWQaYh2GkEF3PL/weCkvypJTVH+Tl+nkBAUEEFi4EhkGausAAnHPSVGqiya+PlMq4eZRHn5yQHIjJHs0X3BmXx5GnFIgS7t8kmj5+3ELb31WntQEzcoyUOmmDyQ=="
}

Poglejmo si, kako od klik.nlb.si dobimo aktivacijsko kodo za PowerAuth.

Najprej od klik.nlb.si zahtevamo openid-connect žeton s pomočjo enkratnega gesla (star sistem).

POST https://klik.nlb.si/authentication/realms/retail/protocol/openid-connect/token HTTP/2.0
x-instana-android: UUIDv4, ni pomembno
content-type: application/x-www-form-urlencoded
user-agent: okhttp/4.12.0
client_id=klik-device-registration
grant_type=password
username=Serijska številka
password=Entrust OTP koda
device_token=DEVICE_TOKEN
platform=ANDROID
Odgovor
{
  "access_token": "JWT žeton",
  "expires_in": 30,
  "refresh_expires_in": 0,
  "token_type": "Bearer",
  "not-before-policy": 0,
  "session_state": "UUIDv4, ni pomembno",
  "scope": "microprofile-jwt"
}

Z OIDC žetonom lahko zahtevamo žeton za aktivacijo uporabnika Wultra.

POST https://klik.nlb.si/authentication/realms/retail/wultra-auth/register-wultra-user HTTP/2.0
authorization: Bearer JWT žeton iz odgovora na prejšnji zahtevek
x-instana-android: UUIDv4, ni pomembno
content-type: application/json; charset=UTF-8
user-agent: okhttp/4.12.0
{
  "appId": "Klik"
}
Odgovor
{
  "registrationId": "UUIDv4, ne bomo potrebovali.",
  "activationQrCodeData": "Z znakom # ločena activationCode in activationCodeSignature.",
  "activationCode": "Aktivacijska koda. Sveti gral.",
  "activationCodeSignature": "70 bajtov kodiranih z base64."
}

Če smo bili uspešni, imamo zdaj PowerAuth aktivacijsko kodo. Od tu naprej so interakcije šifrirane tudi na aplikacijskem nivoju, zato jih ne moremo zlahka analizirati. Posnemali jih bomo s powerauth-java-cmd.jar.

Opazimo, da smo zaključili s klik.nlb.si, v nadaljevanju imamo opravka le še z nlb-si-mtoken.wultra.app.

V naslednjih dveh zahtevkih se naprava s strežnikom najprej dogovori za začasni komunikacijski ključ, nato pa naprava zahteva aktivacijo svojega novonastalega Wultra uporabnika.

POST https://nlb-si-mtoken.wultra.app/enrollment-server/pa/v3/keystore/create HTTP/2.0
X-INSTANA-ANDROID: UUIDv4, ni pomembno
Accept: application/json
Content-Type: application/json
User-Agent: co.infinum.nlb/9.1.1 PowerAuth2/1.9.5 (različica androida in model telefona)
Connection: Keep-Alive
{
  "requestObject": {
    "jwt": "JWT žeton"
  }
}
Odgovor
{
  "status": "OK",
  "responseObject": {
    "jwt": "JWT žeton"
  }
}
POST https://nlb-si-mtoken.wultra.app/enrollment-server/pa/v3/activation/create HTTP/2.0
X-INSTANA-ANDROID: UUIDv4, ni pomembno
Accept: application/json
X-PowerAuth-Encryption: PowerAuth version=“3.3” application_key=“gkOO2lsmea8r8FkGmIdhpA==”
Content-Type: application/json
User-Agent: co.infinum.nlb/9.1.1 PowerAuth2/1.9.5 (različica androida in model telefona)
Connection: Keep-Alive
{
  "encryptedData": "base64",
  "ephemeralPublicKey": "base64",
  "mac": "base64",
  "nonce": "base64",
  "temporaryKeyId": "UUIDv4",
  "timestamp": "unix timestamp"
}
Odgovor
{
  "encryptedData": "base64",
  "mac": "base64",
  "nonce": "base64",
  "timestamp": "unix timestamp"
}

Registracija žetona za FCM po mojih poskusih ni bila potrebna:

POST https://nlb-si-mtoken.wultra.app/enrollment-server/api/push/device/register/token HTTP/2.0
accept-language: en
user-agent: PowerAuthNetworking/1.5.0 co.infinum.nlb/9.1.1 (različica androida, brez modela telefona)
x-powerauth-token: PowerAuth version=“3.3”, token_id=“UUIDv4”, token_digest=“base64”, nonce=“base64”, timestamp=“unix timestamp
x-instana-android: UUIDv4
content-type: application/json; charset=UTF-8
{
  "requestObject": {
    "platform": "fcm",
    "token": "DEVICE_TOKEN"
  }
}
Odgovor
{
  "status": "OK"
}

V nekaterih primerih se pošlje tudi zahtevek za status aktivacije. V mojih poskusih nepotrebno.

POST https://nlb-si-mtoken.wultra.app/enrollment-server/pa/v3/activation/status HTTP/2.0
X-INSTANA-ANDROID: UUIDv4, ni pomembno
Accept: application/json
Content-Type: application/json
User-Agent: co.infinum.nlb/9.1.1 PowerAuth2/1.9.5 (različica androida in model telefona)
Connection: Keep-Alive
{
  "requestObject": {
    "activationId": "UUIDv4",
    "challenge": "base64"
  }
}
Odgovor
{
  "status": "OK",
  "responseObject": {
    "activationId": "UUIDv4",
    "encryptedStatusBlob": "base64",
    "nonce": "base64"
  }
}

Avtorizacija seje

Ko s telefonom avtoriziramo novo sejo, se najprej pošlje t.i. operation claim:

POST https://nlb-si-mtoken.wultra.app/enrollment-server/api/auth/token/app/operation/detail/claim HTTP/2.0
accept-language: en
user-agent: PowerAuthNetworking/1.5.0 co.infinum.nlb/9.1.1 (Android/16)
x-powerauth-token: PowerAuth version=“3.3”, token_id=“UUIDv4”, token_digest=“base64”, nonce=“base64”, timestamp=“unix timestamp
x-instana-android: UUIDv4
content-type: application/json; charset=UTF-8
{
  "requestObject": {
    "id": "operationId iz login linka"
  }
}
Odgovor
{
  "responseObject": {
    "id": "operationId iz login linka",
    "name": "loginweb",
    "data": "A2*Tspletna banka NLB Klik*Tmoj IP naslov*T",
    "status": "PENDING",
    "operationCreated": "UTC ISO 8601",
    "operationExpires": "UTC ISO 8601, 5 minut kasneje",
    "allowedSignatureType": {
      "type": "2FA",
      "variants": [
        "possession_knowledge",
        "possession_biometry"
      ]
    },
    "formData": {
      "title": "Login for web bank",
      "message": "By confirming, you will grant access to your web bank",
      "attributes": [
        {
          "type": "KEY_VALUE",
          "id": "operation.userAgent",
          "label": "Application",
          "value": "spletna banka NLB Klik"
        },
        {
          "type": "KEY_VALUE",
          "id": "operation.ipAddress",
          "label": "IP Adddress",
          "value": "moj IP naslov"
        },
        {
          "type": "KEY_VALUE",
          "id": "operation.location",
          "label": "Location",
          "value": ""
        }
      ]
    }
  },
  "status": "OK",
  "currentTimestamp": "UTC ISO 8601"
}

nato pa še dejanska avtorizacija

POST https://nlb-si-mtoken.wultra.app/enrollment-server/api/auth/token/app/operation/authorize HTTP/2.0
accept-language: en
user-agent: PowerAuthNetworking/1.5.0 co.infinum.nlb/9.1.1 (Android/16)
x-powerauth-authorization: PowerAuth pa_version=“3.3”, pa_activation_id=“UUIDv4”, pa_application_key=“gkOO2lsmea8r8FkGmIdhpA==”, pa_nonce=“16 naključnih bajtov, kodiranih z base64”, pa_signature_type=“possession_knowledge”, pa_signature=“32 naključnih bajtov, kodiranih z base64
x-instana-android: UUIDv4, ni pomembno
content-type: application/json; charset=UTF-8
{
  "requestObject": {
    "data": "A2*Tspletna banka NLB Klik*Tmoj IP naslov*T",
    "id": "operationId iz QR kode",
    "mobileTokenData": {
      "deviceToken": {
        "platform": "ANDROID",
        "token": "DEVICE_TOKEN"
      }
    },
    "proximityCheck": {
      "otp": "OTP iz QR kode",
      "timestampReceived": "UTC ISO 8601",
      "timestampSent": "UTC ISO 8601",
      "type": "DEEPLINK"
    }
  }
}
Odgovor
{
  "status": "OK"
}

Ob normalnem delovanju opazimo še kup drugih nepovezanih zahtevkov na domene:

Te lahko mirno blokiramo, ne da bi preprečili delovanje aplikacije.


Dodatno

Prenos apk-ja

Najprej vključimo USB razhroščevanje. Glej https://developer.android.com/tools/adb#Enabling.

Sedaj lahko z ukazom adb shell pm list packages | grep nlb dobimo polno ime aplikacije Klik, ki jo prenesemo z

adb shell pm path co.infinum.nlb /data/app/~~SFowXO9VvoPL6wq1j1ed7A==/co.infinum.nlb-L3zT9kaZXe_Ex8X5F9RksA==/base.apk
adb pull /data/app/~~SFowXO9VvoPL6wq1j1ed7A==/co.infinum.nlb-L3zT9kaZXe_Ex8X5F9RksA==/base.apk

Pazi, ime aplikacije bo pri tebi drugačno!

Za analizo kode uporabimo orodje apktool

Uporaba mitmproxy brez rootanega telefona

Ker si mitmproxy sam podpisuje TLS certifikate za spletišča, ki jih impersonira, mora telefon zaupati njegovemu korenskemu izdajatelju (CA).

Android je dodajanje sistemskih CA certifikatov prepovedal za navadne smrtnike, zato je to možno zgolj na rooted telefonih. Še vedno lahko dodamo uporabniški CA, a večina aplikacij le-tem ne zaupa.

Če smo že potegnili apk na računalnik, lahko popravimo res/xml/network_security_config.xml:

res/xml/network_security_config.xml
<network-security-config>
     <debug-overrides>
          <trust-anchors>
               <!-- Trust preinstalled CAs -->
               <certificates src="system" />
               <!-- Additionally trust user added CAs -->
               <certificates src="user" />
          </trust-anchors>
     </debug-overrides>
</network-security-config>

Nov apk pošljemo na telefon, zbrišemo star NLB Klik in naložimo popravljenega. Ta bo zaupal uporabniškim CA-jem.