Skip to content

Commit 361f2ef

Browse files
rhnraphaelm
andauthored
VoP support (#199)
* WIP: Add VoP segments * VoP generally works * works also with full match * document a bit * Improved implementation * Fix failing tests --------- Co-authored-by: Raphael Michel <[email protected]>
1 parent ab16dac commit 361f2ef

File tree

7 files changed

+308
-24
lines changed

7 files changed

+308
-24
lines changed

docs/registration.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Registration necessary
2+
======================
3+
4+
As of September 14th, 2019, all FinTS programs need to be registered with the ZKA or
5+
banks will block access. You need to fill out a PDF form and will be assigned a
6+
product ID that you can pass above.
7+
8+
Click here to read more about the `registration process`_.
9+
10+
11+
.. _registration process: https://www.hbci-zka.de/register/prod_register.htm

docs/transfers.rst

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@ You can create a simple SEPA transfer using this convenient client method:
1212
:members: simple_sepa_transfer
1313
:noindex:
1414

15+
The return value may be a `NeedVOPResponse` in which case you need to call `approve_vop_response` to proceed.
16+
17+
At any point, you might receive a `NeedTANResponse`.
1518
You should then enter a TAN, read our chapter :ref:`tans` to find out more.
1619

20+
.. autoclass:: fints.client.FinTS3PinTanClient
21+
:members: approve_vop_response
22+
:noindex:
23+
24+
1725
Advanced mode
1826
-------------
1927

@@ -55,20 +63,37 @@ Full example
5563
endtoend_id='NOTPROVIDED',
5664
)
5765
58-
while isinstance(res, NeedTANResponse):
59-
print("A TAN is required", res.challenge)
60-
61-
if getattr(res, 'challenge_hhduc', None):
62-
try:
63-
terminal_flicker_unix(res.challenge_hhduc)
64-
except KeyboardInterrupt:
65-
pass
66-
67-
if result.decoupled:
68-
tan = input('Please press enter after confirming the transaction in your app:')
69-
else:
70-
tan = input('Please enter TAN:')
71-
res = client.send_tan(res, tan)
66+
while isinstance(res, NeedTANResponse | NeedVOPResponse):
67+
if isinstance(res, NeedTANResponse):
68+
print("A TAN is required", res.challenge)
69+
70+
if getattr(res, 'challenge_hhduc', None):
71+
try:
72+
terminal_flicker_unix(res.challenge_hhduc)
73+
except KeyboardInterrupt:
74+
pass
75+
76+
if result.decoupled:
77+
tan = input('Please press enter after confirming the transaction in your app:')
78+
else:
79+
tan = input('Please enter TAN:')
80+
res = client.send_tan(res, tan)
81+
elif isinstance(res, NeedVOPResponse):
82+
if res.vop_result.vop_single_result.result == "RVMC":
83+
print("Payee name is a close match")
84+
print("Name retrieved by bank:", res.vop_result.vop_single_result.close_match_name)
85+
if res.vop_result.vop_single_result.other_identification:
86+
print("Other info retrieved by bank:", res.vop_result.vop_single_result.other_identification)
87+
elif res.vop_result.vop_single_result.result == "RVNM":
88+
print("Payee name does not match match")
89+
elif res.vop_result.vop_single_result.result == "RVNA":
90+
print("Payee name could not be verified")
91+
print("Reason:", res.vop_result.vop_single_result.na_reason)
92+
elif res.vop_result.vop_single_result.result == "PDNG":
93+
print("Payee name could not be verified (pending state, can't be handled by this library)")
94+
print("Do you want to continue? Your bank will not be liable if the money ends up in the wrong place.")
95+
input('Please press enter to confirm or Ctrl+C to cancel')
96+
res = client.approve_vop_response(res)
7297
7398
print(res.status)
7499
print(res.responses)

docs/trouble.rst

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ the problem.
6565
return f.send_tan(response, tan)
6666
6767
68+
def ask_for_vop(response: NeedVOPResponse):
69+
if response.vop_result.vop_single_result.result == "RVMC":
70+
print("Payee name is a close match")
71+
print("Name retrieved by bank:", response.vop_result.vop_single_result.close_match_name)
72+
if response.vop_result.vop_single_result.other_identification:
73+
print("Other info retrieved by bank:", response.vop_result.vop_single_result.other_identification)
74+
elif response.vop_result.vop_single_result.result == "RVNM":
75+
print("Payee name does not match match")
76+
elif response.vop_result.vop_single_result.result == "RVNA":
77+
print("Payee name could not be verified")
78+
print("Reason:", response.vop_result.vop_single_result.na_reason)
79+
elif response.vop_result.vop_single_result.result == "PDNG":
80+
print("Payee name could not be verified (pending state, can't be handled by this library)")
81+
print("Do you want to continue? Your bank will not be liable if the money ends up in the wrong place.")
82+
input('Please press enter to confirm or Ctrl+C to cancel')
83+
return f.approve_vop_response(response)
84+
85+
6886
# Open the actual dialog
6987
with f:
7088
# Since PSD2, a TAN might be needed for dialog initialization. Let's check if there is one required
@@ -172,8 +190,11 @@ the problem.
172190
endtoend_id='NOTPROVIDED',
173191
)
174192
175-
while isinstance(res, NeedTANResponse):
176-
res = ask_for_tan(res)
193+
while isinstance(res, NeedTANResponse | NeedVOPResponse):
194+
if isinstance(res, NeedTANResponse):
195+
res = ask_for_tan(res)
196+
elif isinstance(res, NeedVOPResponse):
197+
res = ask_for_vop(res)
177198
elif choice == 11:
178199
print("Select statement")
179200
statements = f.get_statements(account)

fints/client.py

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
PinTanTwoStepAuthenticationMechanism,
2828
)
2929
from .segments.accounts import HISPA1, HKSPA1
30-
from .segments.auth import HIPINS1, HKTAB4, HKTAB5, HKTAN2, HKTAN3, HKTAN5, HKTAN6, HKTAN7
30+
from .segments.auth import HIPINS1, HKTAB4, HKTAB5, HKTAN2, HKTAN3, HKTAN5, HKTAN6, HKTAN7, HIVPPS1, HIVPP1, PSRD1, HKVPA1
3131
from .segments.bank import HIBPA3, HIUPA4, HKKOM4
3232
from .segments.debit import (
3333
HKDBS1, HKDBS2, HKDMB1, HKDMC1, HKDME1, HKDME2,
@@ -828,7 +828,7 @@ def simple_sepa_transfer(self, account: SEPAAccount, iban: str, bic: str,
828828
:param reason: Transfer reason
829829
:param instant_payment: Whether to use instant payment (defaults to ``False``)
830830
:param endtoend_id: End-to-end-Id (defaults to ``NOTPROVIDED``)
831-
:return: Returns either a NeedRetryResponse or TransactionResponse
831+
:return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse
832832
"""
833833
config = {
834834
"name": account_name,
@@ -908,7 +908,7 @@ def sepa_transfer(self, account: SEPAAccount, pain_message: str, multiple=False,
908908
if book_as_single:
909909
seg.request_single_booking = True
910910

911-
return self._send_with_possible_retry(dialog, seg, self._continue_sepa_transfer)
911+
return self._send_pay_with_possible_retry(dialog, seg, self._continue_sepa_transfer)
912912

913913
def _continue_sepa_transfer(self, command_seg, response):
914914
retval = TransactionResponse(response)
@@ -1074,19 +1074,56 @@ def resume_dialog(self, dialog_data):
10741074
self._standing_dialog = None
10751075

10761076

1077+
class NeedVOPResponse(NeedRetryResponse):
1078+
1079+
def __init__(self, vop_result, command_seg, resume_method=None):
1080+
self.vop_result = vop_result
1081+
self.command_seg = command_seg
1082+
if hasattr(resume_method, '__func__'):
1083+
self.resume_method = resume_method.__func__.__name__
1084+
else:
1085+
self.resume_method = resume_method
1086+
1087+
def __repr__(self):
1088+
return '<o.__class__.__name__(vop_result={o.vop_result!r})>'.format(o=self)
1089+
1090+
@classmethod
1091+
def _from_data_v1(cls, data):
1092+
if data["version"] == 1:
1093+
segs = SegmentSequence(data['segments_bin']).segments
1094+
return cls(segs[0], segs[1], resume_method=data['resume_method'])
1095+
1096+
raise Exception("Wrong blob data version")
1097+
1098+
def get_data(self) -> bytes:
1099+
"""Return a compressed datablob representing this object.
1100+
1101+
To restore the object, use :func:`fints.client.NeedRetryResponse.from_data`.
1102+
"""
1103+
data = {
1104+
"_class_name": self.__class__.__name__,
1105+
"version": 1,
1106+
"segments_bin": SegmentSequence([self.vop_result, self.command_seg]).render_bytes(),
1107+
"resume_method": self.resume_method,
1108+
}
1109+
return compress_datablob(DATA_BLOB_MAGIC_RETRY, 1, data)
1110+
1111+
10771112
class NeedTANResponse(NeedRetryResponse):
10781113
challenge_raw = None #: Raw challenge as received by the bank
10791114
challenge = None #: Textual challenge to be displayed to the user
10801115
challenge_html = None #: HTML-safe challenge text, possibly with formatting
10811116
challenge_hhduc = None #: HHD_UC challenge to be transmitted to the TAN generator
10821117
challenge_matrix = None #: Matrix code challenge: tuple(mime_type, data)
10831118
decoupled = None #: Use decoupled process
1119+
vop_result = None #: VoP result
10841120

1085-
def __init__(self, command_seg, tan_request, resume_method=None, tan_request_structured=False, decoupled=False):
1121+
def __init__(self, command_seg, tan_request, resume_method=None, tan_request_structured=False, decoupled=False, vop_result=None):
10861122
self.command_seg = command_seg
10871123
self.tan_request = tan_request
10881124
self.tan_request_structured = tan_request_structured
10891125
self.decoupled = decoupled
1126+
self.vop_result = vop_result
10901127
if hasattr(resume_method, '__func__'):
10911128
self.resume_method = resume_method.__func__.__name__
10921129
else:
@@ -1315,6 +1352,23 @@ def _get_tan_segment(self, orig_seg, tan_process, tan_seg=None):
13151352

13161353
return seg
13171354

1355+
def _find_vop_format_for_segment(self, seg):
1356+
vpps = self.bpd.find_segment_first('HIVPPS')
1357+
if not vpps:
1358+
return
1359+
1360+
needed = str(seg.header.type) in list(vpps.parameter.payment_order_segment)
1361+
1362+
if not needed:
1363+
return
1364+
1365+
bank_supported = str(vpps.parameter.supported_report_formats)
1366+
1367+
if "sepade.pain.002.001.10.xsd" != bank_supported:
1368+
logger.warning("No common supported SEPA version. Defaulting to what bank supports and hoping for the best: %s.", bank_supported)
1369+
1370+
return bank_supported
1371+
13181372
def _need_twostep_tan_for_segment(self, seg):
13191373
if not self.selected_security_function or self.selected_security_function == '999':
13201374
return False
@@ -1351,13 +1405,99 @@ def _send_with_possible_retry(self, dialog, command_seg, resume_func):
13511405
response = dialog.send(command_seg)
13521406

13531407
return resume_func(command_seg, response)
1408+
1409+
def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func):
1410+
"""
1411+
This adds VoP under the assumption that TAN will be sent,
1412+
There appears to be no VoP flow without sending any authentication.
1413+
1414+
There are really 2 VoP flows: with a full match and otherwise.
1415+
The second flow returns a NeedVOPResponse as intended by the specification flowcharts.
1416+
In this case cases, the application should ask the user for confirmation based on HIVPP data in resp.vop_result.
1417+
1418+
The kind of response is in resp.vop_result.single_vop_result.result:
1419+
- 'RCVC' - full match
1420+
- 'RVMC' - partial match, extra info in single_vop_result.close_match_name and .other_identification.
1421+
- 'RVNM' - no match, no extra info seen
1422+
- 'RVNA' - check not available, reason in single_vop_result.na_reason
1423+
- 'PDNG' - pending, seems related to something not implemented right now.
1424+
"""
1425+
vop_seg = []
1426+
vop_standard = self._find_vop_format_for_segment(command_seg)
1427+
if vop_standard:
1428+
from .segments.auth import HKVPP1
1429+
vop_seg = [HKVPP1(supported_reports=PSRD1(psrd=[vop_standard]))]
1430+
1431+
with dialog:
1432+
if self._need_twostep_tan_for_segment(command_seg):
1433+
tan_seg = self._get_tan_segment(command_seg, '4')
1434+
segments = vop_seg + [command_seg, tan_seg]
1435+
1436+
response = dialog.send(*segments)
1437+
1438+
if vop_standard:
1439+
hivpp = response.find_segment_first(HIVPP1, throw=True)
1440+
1441+
vop_result = hivpp.vop_single_result
1442+
if vop_result.result in ('RVNA', 'RVNM', 'RVMC'): # Not Applicable, No Match, Close Match
1443+
return NeedVOPResponse(
1444+
vop_result=hivpp,
1445+
command_seg=command_seg,
1446+
resume_method=resume_func,
1447+
)
1448+
else:
1449+
hivpp = None
1450+
1451+
for resp in response.responses(tan_seg):
1452+
if resp.code in ('0030', '3955'):
1453+
return NeedTANResponse(
1454+
command_seg,
1455+
response.find_segment_first('HITAN'),
1456+
resume_func,
1457+
self.is_challenge_structured(),
1458+
resp.code == '3955',
1459+
hivpp,
1460+
)
1461+
if resp.code.startswith('9'):
1462+
raise Exception("Error response: {!r}".format(response))
1463+
else:
1464+
response = dialog.send(command_seg)
1465+
1466+
return resume_func(command_seg, response)
13541467

13551468
def is_challenge_structured(self):
13561469
param = self.get_tan_mechanisms()[self.get_current_tan_mechanism()]
13571470
if hasattr(param, 'challenge_structured'):
13581471
return param.challenge_structured
13591472
return False
13601473

1474+
def approve_vop_response(self, challenge: NeedVOPResponse):
1475+
"""
1476+
Approves an operation that had a non-match VoP (verification of payee) response.
1477+
1478+
:param challenge: NeedVOPResponse to respond to
1479+
:return: New response after sending VOP response
1480+
"""
1481+
with self._get_dialog() as dialog:
1482+
vop_seg = [HKVPA1(vop_id=challenge.vop_result.vop_id)]
1483+
tan_seg = self._get_tan_segment(challenge.command_seg, '4')
1484+
segments = vop_seg + [challenge.command_seg, tan_seg]
1485+
response = dialog.send(*segments)
1486+
1487+
for resp in response.responses(tan_seg):
1488+
if resp.code in ('0030', '3955'):
1489+
return NeedTANResponse(
1490+
challenge.command_seg,
1491+
response.find_segment_first('HITAN'),
1492+
challenge.resume_method,
1493+
self.is_challenge_structured(),
1494+
resp.code == '3955',
1495+
challenge.vop_result,
1496+
)
1497+
1498+
resume_func = getattr(self, challenge.resume_method)
1499+
return resume_func(challenge.command_seg, response)
1500+
13611501
def send_tan(self, challenge: NeedTANResponse, tan: str):
13621502
"""
13631503
Sends a TAN to confirm a pending operation.
@@ -1370,15 +1510,18 @@ def send_tan(self, challenge: NeedTANResponse, tan: str):
13701510
:param tan: TAN value
13711511
:return: New response after sending TAN
13721512
"""
1373-
13741513
with self._get_dialog() as dialog:
13751514
if challenge.decoupled:
13761515
tan_seg = self._get_tan_segment(challenge.command_seg, 'S', challenge.tan_request)
13771516
else:
13781517
tan_seg = self._get_tan_segment(challenge.command_seg, '2', challenge.tan_request)
13791518
self._pending_tan = tan
13801519

1381-
response = dialog.send(tan_seg)
1520+
vop_seg = []
1521+
if challenge.vop_result and challenge.vop_result.vop_single_result.result == 'RCVC':
1522+
vop_seg = [HKVPA1(vop_id=challenge.vop_result.vop_id)]
1523+
segments = vop_seg + [tan_seg]
1524+
response = dialog.send(*segments)
13821525

13831526
if challenge.decoupled:
13841527
# TAN process = S

fints/fields.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,21 @@ def _render_value(self, value):
291291
return super()._render_value(val)
292292

293293

294+
class TimestampField(DataElementField):
295+
# Defined in the VoP standard, but missing in the Formals document. We just treat it as
296+
# opaque bytes.
297+
type = 'tsp'
298+
_DOC_TYPE = bytes
299+
300+
def _render_value(self, value):
301+
retval = bytes(value)
302+
self._check_value_length(retval)
303+
return retval
304+
305+
def _parse_value(self, value):
306+
return bytes(value)
307+
308+
294309
class PasswordField(AlphanumericField):
295310
type = ''
296311
_DOC_TYPE = Password

0 commit comments

Comments
 (0)