Skip to content
Open
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