Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23,487 changes: 23,487 additions & 0 deletions docs/fints-def/FinTS_3.0_Formals_2017-10-06_final_version.md

Large diffs are not rendered by default.

Binary file not shown.
143,564 changes: 143,564 additions & 0 deletions docs/fints-def/FinTS_3.0_Messages_Geschaeftsvorfaelle_2015-08-07_final_version.md

Large diffs are not rendered by default.

Binary file not shown.
22,774 changes: 22,774 additions & 0 deletions ...-def/FinTS_3.0_Security_Sicherheitsverfahren_HBCI_Rel_20130718_final_version.md

Large diffs are not rendered by default.

Binary file not shown.
29,164 changes: 29,164 additions & 0 deletions ...-def/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2018-02-23_final_version.md

Large diffs are not rendered by default.

Binary file not shown.
2 changes: 2 additions & 0 deletions docs/tested.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ Postbank Yes
BBBank eG Yes Yes
Sparkasse Heidelberg Yes
comdirect Yes Yes
Consorsbank Yes Yes
======================================== ============ ======== ======== ======

Tested security functions
-------------------------

* ``900`` "photoTAN" / "Secure Plus" (QR code)
* ``902`` "photoTAN"
* ``921`` "pushTAN"
* ``930`` "mobile TAN"
Expand Down
11 changes: 10 additions & 1 deletion docs/transfers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,22 @@ Full example
if isinstance(res, NeedTANResponse):
print("A TAN is required", res.challenge)

# photoTAN / QR code: save and display the image
if getattr(res, 'challenge_matrix', None):
mime_type, image_data = res.challenge_matrix
with open('tan_challenge.png', 'wb') as f:
f.write(image_data)
print(f"QR code saved to tan_challenge.png ({len(image_data)} bytes)")
# Optionally open the image automatically:
# import subprocess; subprocess.Popen(['open', 'tan_challenge.png'])

if getattr(res, 'challenge_hhduc', None):
try:
terminal_flicker_unix(res.challenge_hhduc)
except KeyboardInterrupt:
pass

if result.decoupled:
if res.decoupled:
tan = input('Please press enter after confirming the transaction in your app:')
else:
tan = input('Please enter TAN:')
Expand Down
223 changes: 205 additions & 18 deletions fints/client.py

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions fints/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,27 @@ def init(self, *extra_segments):
retval = self.send(*segments, internal_send=True)

if tan_seg:
for resp in retval.responses(tan_seg):
if resp.code in ('0030', '3955'):
# Some banks (e.g. Consorsbank) attach the login-SCA
# 0030/3955 response to the HKIDN segment instead of the
# HKTAN segment, so check both references.
for ref in (tan_seg, segments[0]):
if self.client.init_tan_response is not None:
break
ref_responses = list(retval.responses(ref))
if any(resp.code in ('0030', '3955') for resp in ref_responses):
self.client.init_tan_response = NeedTANResponse(
None,
retval.find_segment_first('HITAN'),
'_continue_dialog_initialization',
self.client.is_challenge_structured(),
False,
)
if resp.code == '3955':
# 3955 ("Sicherheitsfreigabe erfolgt über anderen
# Kanal") flags decoupled app approval; it may appear
# alongside 0030, so check all responses, not just
# the first matching code.
if any(resp.code == '3955' for resp in ref_responses):
self.client.init_tan_response.decoupled = True
break

self.need_init = False
return retval
Expand Down
14 changes: 14 additions & 0 deletions fints/formals.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,12 @@ def from_sepa_account(cls, acc):
return cls(
iban=acc.iban,
bic=acc.bic,
account_number=acc.accountnumber,
subaccount_number=acc.subaccount,
bank_identifier=BankIdentifier(
country_identifier=BankIdentifier.COUNTRY_ALPHA_TO_NUMERIC[acc.bic[4:6]],
bank_code=acc.blz
) if acc.blz else None,
)


Expand Down Expand Up @@ -868,6 +874,14 @@ class BatchTransferParameter1(DataElementGroup):
single_booking_allowed = DataElementField(type='jn', _d="Einzelbuchung erlaubt")


class ScheduledTransferParameter1(DataElementGroup):
"""Parameter terminierte SEPA-Überweisung, version 1

Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """
min_advance_days = DataElementField(type='num', max_length=4, _d="Mindestvorlaufzeit")
max_advance_days = DataElementField(type='num', max_length=4, _d="Maximaler Vorlauf")


@doc_enum
class ServiceType2(RepresentableEnum):
T_ONLINE = 1 # doc: T-Online
Expand Down
9 changes: 8 additions & 1 deletion fints/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,15 @@ def sign_prepare(self, message: FinTSMessage):
_now = datetime.datetime.now()
rand = random.SystemRandom()

# Per ZKA FinTS spec, two-step TAN methods (security_function != '999')
# require security_method_version=2 in the SecurityProfile.
if self.security_function and self.security_function != '999':
security_method_version = 2
else:
security_method_version = 1

self.pending_signature = HNSHK4(
security_profile=SecurityProfile(SecurityMethod.PIN, 1),
security_profile=SecurityProfile(SecurityMethod.PIN, security_method_version),
security_function=self.security_function,
security_reference=rand.randint(1000000, 9999999),
security_application_area=SecurityApplicationArea.SHM,
Expand Down
26 changes: 25 additions & 1 deletion fints/segments/transfer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fints.fields import DataElementField, DataElementGroupField
from fints.formals import KTI1, Amount1, BatchTransferParameter1
from fints.formals import KTI1, Amount1, BatchTransferParameter1, ScheduledTransferParameter1

from .base import FinTS3Segment, ParameterSegment

Expand All @@ -12,6 +12,30 @@ class HKCCS1(FinTS3Segment):
sepa_descriptor = DataElementField(type='an', max_length=256, _d="SEPA Descriptor")
sepa_pain_message = DataElementField(type='bin', _d="SEPA pain message")


class HKCSE1(FinTS3Segment):
"""Terminierte SEPA-Überweisung einreichen, version 1

Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """
account = DataElementGroupField(type=KTI1, _d="Kontoverbindung international")
sepa_descriptor = DataElementField(type='an', max_length=256, _d="SEPA Descriptor")
sepa_pain_message = DataElementField(type='bin', _d="SEPA pain message")


class HICSE1(FinTS3Segment):
"""Einreichung terminierter SEPA-Überweisung bestätigen, version 1

Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """
order_id = DataElementField(type='an', max_length=99, required=False, _d="Auftragsidentifikation")


class HICSES1(ParameterSegment):
"""Terminierte SEPA-Überweisung einreichen Parameter, version 1

Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """
parameter = DataElementGroupField(type=ScheduledTransferParameter1, _d="Parameter terminierte SEPA-Überweisung einreichen")


class HKIPZ1(FinTS3Segment):
"""SEPA-instant Einzelüberweisung, version 1

Expand Down
224 changes: 224 additions & 0 deletions sample_consorsbank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
Sample: Consorsbank (BLZ 76030080) with python-fints.

Demonstrates fetching accounts/transactions and making a SEPA transfer with
either of Consorsbank's two current TAN methods.

TAN methods
-----------
Consorsbank advertises two two-step TAN mechanisms:

* **901 "Consorsbank/myPrivateBank App"** (``zka_id = "Decoupled"``) — the new
Consorsbank App. Login asks for a *typed* 9-digit TAN generated in the app;
a SEPA transfer is *approved in the app* (decoupled), no TAN is typed.
* **900 "SecurePlus TAN Generator"** (``zka_id = "photoTAN"``) — login needs no
TAN (the bank answers the dialog-init with ``3076``); a SEPA transfer returns
an order-bound **photoTAN QR image** (``response.challenge_matrix``) that is
scanned, after which the resulting TAN is typed.

Both were exercised end-to-end against Consorsbank. A real SEPA transfer with
mechanism ``901`` was accepted and booked.

.. note::
The old **SecurePlus *App*** (the smartphone app, distinct from the
SecurePlus *TAN-generator device*) was shut down for Consorsbank online
banking on **2026-04-25**; since then it returns "TAN-Verfahren ungültig"
and any TAN it produces — including ones scanned from the ``900`` photoTAN
QR — is rejected with ``9941 TAN ungültig``. Use the new **Consorsbank App**
(``901``) or the **physical SecurePlus TAN-generator device** (``900``).
The ``900`` code path here is correct; only the decommissioned app's TAN is
refused by the bank. (Source: kritische-anleger.de SecurePlus-App shutdown
report, and the official Consorsbank HBCI FAQ.)

Protocol quirks handled by python-fints (see PR #209 and the login-SCA fix)
--------------------------------------------------------------------------
1. ``security_method_version=2`` for two-step TAN.
2. Full account details in ``KTI1.from_sepa_account``.
3. ``force_twostep_tan`` for segments the bank requires a TAN on despite
reporting otherwise in HIPINS (otherwise: ``9075``).
4. The TAN-required response ``0030`` is attached to the **command segment**
(``HKCCS``) instead of ``HKTAN``.
5. The **login** strong-customer-authentication response ``0030`` is attached
to the **HKIDN** segment of the dialog-init, not ``HKTAN``. Without
detecting it, the next command aborts the dialog with ``9800/9120``.
6. For decoupled app approval, Consorsbank returns ``0030`` **together with**
``3955`` ("Sicherheitsfreigabe erfolgt über anderen Kanal"). python-fints
now flags the challenge as ``decoupled`` whenever ``3955`` is present.

What the user sees with the Consorsbank App (mechanism 901)
-----------------------------------------------------------
* **Login**: the bank asks for a *typed* TAN. Open the Consorsbank App,
generate the (9-digit) TAN and type it in. ``response.decoupled`` is False.
* **SEPA transfer**: the bank pushes the order to the app for approval.
``response.decoupled`` is True; the user approves in the app and the client
polls until the bank confirms — no TAN is typed.

Usage:
pip install python-fints python-dotenv
python sample_consorsbank.py

Environment variables (or .env file):
FINTS_BLZ=76030080
FINTS_USER=<your user id>
FINTS_PIN=<your PIN>
FINTS_SERVER=https://brokerage-hbci.consorsbank.de/hbci
FINTS_PRODUCT_ID=<your registered product id>
FINTS_TAN_MECHANISM=901 # 901 = Consorsbank App, 900 = SecurePlus generator
MY_IBAN=<IBAN of the account to use>
# To actually send money, set all of these:
TRANSFER_TO_IBAN=<recipient IBAN>
TRANSFER_TO_NAME=<recipient name>
TRANSFER_AMOUNT=1.00
TRANSFER_REASON=Test transfer
"""

import os
import time
import logging
from datetime import date, timedelta
from decimal import Decimal

from fints.client import FinTS3PinTanClient, NeedTANResponse, NeedVOPResponse

logging.basicConfig(level=logging.WARNING)

try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass


def handle_tan(response, client):
"""Resolve TAN / VoP / decoupled challenges.

* Decoupled (e.g. Consorsbank App): the user approves the order inside the
banking app; we poll with ``send_tan`` until the bank confirms.
* photoTAN / QR: an image is shown to scan, then the TAN is typed.
* Plain: the user types the TAN shown by the app/generator.
"""
while isinstance(response, (NeedTANResponse, NeedVOPResponse)):
if isinstance(response, NeedVOPResponse):
# Verification of Payee result; approve and continue.
response = client.approve_vop_response(response)
continue

print(f"\nTAN required: {response.challenge}")

# Decoupled app approval (Consorsbank App): no TAN is typed.
if response.decoupled:
input("Approve the request in your Consorsbank App, then press ENTER... ")
# Poll the bank until the decoupled approval is registered.
response = client.send_tan(response, "")
while isinstance(response, NeedTANResponse) and response.decoupled:
time.sleep(4)
response = client.send_tan(response, "")
continue

# photoTAN / QR code image (e.g. SecurePlus generator).
if response.challenge_matrix:
mime_type, image_data = response.challenge_matrix
ext = ".png" if "png" in mime_type else ".jpg"
img_path = f"tan_challenge{ext}"
with open(img_path, "wb") as f:
f.write(image_data)
print(f" Challenge image saved to {img_path} ({len(image_data)} bytes)")
tan = input("Scan the image and enter the TAN: ")
else:
# Plain typed TAN (e.g. Consorsbank App login TAN, 9 digits).
tan = input("Enter the TAN from your app/generator: ")

response = client.send_tan(response, tan)

return response


def main():
blz = os.environ.get("FINTS_BLZ", "76030080")
user = os.environ["FINTS_USER"]
pin = os.environ["FINTS_PIN"]
server = os.environ.get("FINTS_SERVER", "https://brokerage-hbci.consorsbank.de/hbci")
product_id = os.environ.get("FINTS_PRODUCT_ID")
mechanism = os.environ.get("FINTS_TAN_MECHANISM", "901")
my_iban = os.environ.get("MY_IBAN")

client = FinTS3PinTanClient(
bank_identifier=blz,
user_id=user,
pin=pin,
server=server,
product_id=product_id,
# Consorsbank reports HKKAZ:N / HKSAL:N in HIPINS but actually requires
# a TAN for them; HKCCS always requires a TAN.
force_twostep_tan={"HKKAZ", "HKSAL"},
)

# 901 = Consorsbank App (current), 900 = physical SecurePlus TAN generator.
if not client.get_current_tan_mechanism():
client.fetch_tan_mechanisms()
client.set_tan_mechanism(mechanism)

with client:
# Login strong-customer-authentication (typed TAN with the app).
if client.init_tan_response:
handle_tan(client.init_tan_response, client)

# --- Fetch accounts ---
accounts = client.get_sepa_accounts()
if isinstance(accounts, NeedTANResponse):
accounts = handle_tan(accounts, client)

print("Accounts:")
for a in accounts:
print(f" {a.iban} (BIC: {a.bic})")

if my_iban:
account = next((a for a in accounts if a.iban == my_iban), None)
if not account:
print(f"Account {my_iban} not found")
return
else:
account = accounts[0]
print(f"\nUsing account: {account.iban}")

# --- Fetch transactions ---
print("\nFetching transactions (last 30 days)...")
start_date = date.today() - timedelta(days=30)
res = client.get_transactions(account, start_date=start_date)
if isinstance(res, (NeedTANResponse, NeedVOPResponse)):
res = handle_tan(res, client)
if res:
print(f"Found {len(res)} transactions; showing last 5:")
for t in res[-5:]:
d = t.data
amt = d.get("amount")
amount_str = f"{amt.amount:>10.2f} {amt.currency}" if amt else ""
print(f" {d.get('date')} {amount_str} {d.get('applicant_name', '')}")
else:
print("No transactions found.")

# --- SEPA transfer (approved in the Consorsbank App) ---
to_iban = os.environ.get("TRANSFER_TO_IBAN")
if to_iban:
print(f"\nSubmitting SEPA transfer to {to_iban} ...")
res = client.simple_sepa_transfer(
account=account,
iban=to_iban,
bic=os.environ.get("TRANSFER_TO_BIC", ""),
recipient_name=os.environ["TRANSFER_TO_NAME"],
amount=Decimal(os.environ.get("TRANSFER_AMOUNT", "1.00")),
account_name=os.environ.get("TRANSFER_FROM_NAME", user),
reason=os.environ.get("TRANSFER_REASON", "Test transfer"),
)
# The bank pushes the order to the Consorsbank App for approval.
res = handle_tan(res, client)
print(f"Transfer result: {getattr(res, 'status', None)} {getattr(res, 'responses', None)}")
else:
print("\n(Set TRANSFER_TO_IBAN/TRANSFER_TO_NAME to perform a transfer.)")

print("\nDone!")


if __name__ == "__main__":
main()
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ def make_answer(self, dialog_id, message):

datadict['pending'].pop(ref, None)

hkcse = re.search(rb"'HKCSE:(\d+):1.*@\d+@(.*)/Document>'", message)
if hkcse:
segno = hkcse.group(1).decode('us-ascii')
pain = hkcse.group(2).decode('utf-8')

memomatch = re.search(r"<RmtInf[^>]*>\s*<Ustrd[^>]*>\s*([^<]+)\s*</Ustrd", pain)
recvrmatch = re.search(r"<CdtrAcct[^>]*>\s*<Id[^>]*>\s*<IBAN[^>]*>\s*([^<]+)\s*</IBAN", pain)
amountmatch = re.search(r"<Amt[^>]*><InstdAmt[^>]*>\s*([^<]+)\s*</InstdAmt", pain)
datematch = re.search(r"<ReqdExctnDt[^>]*>(?:\s*<Dt[^>]*>)?\s*([^<]+)\s*(?:</Dt>)?</ReqdExctnDt", pain)

if memomatch and recvrmatch and amountmatch and datematch:
result.append("HIRMS::2:{}+0010::Scheduled {} on {}'".format(segno, amountmatch.group(1), datematch.group(1)).encode('iso-8859-1'))
result.append("HICSE::1:{}+ORDER-{}'".format(segno, segno).encode('us-ascii'))

return b"".join(result)

def process_message(self, message):
Expand Down
Loading