fints 0.1.2
fints: ^0.1.2 copied to clipboard
FinTS 3.0 and 4.1 client for German banks. Retrieves accounts, balances and transactions with PIN/TAN, two-step TAN, decoupled approval and PSD2 strong customer authentication.
fints #
A pure Dart FinTS client for German banks, usable from any Flutter app (iOS, Android, desktop). It reads accounts, balances and transactions over FinTS 3.0 (HBCI segment syntax) and FinTS 4.1 (XML, namespaces 4.1.1 and 4.1).
Features #
- PIN/TAN security, the procedure German banks offer to consumers:
- The one-step method (
999) and two-step TAN methods of process variant 2 (HKTANprocess 4, then 2 orS), which every bank offers for PSD2. A bank that offers only variant 1 raisesFinTsUnsupportedException. - PSD2 strong customer authentication (SCA) at dialog start (
HKTANprocess 4; in 4.1SCARequestedandDistSigsSign), and per transaction where the bank asks for it, including SCA exemptions (3076). - App approval (decoupled: pushTAN, SecureGo plus, …) with status requests (
HKTAN#7 processS), timed by the bank's parameters. This is FinTS 3.0; the 4.1 documents do not define it, see below. - TAN media lookup and selection (
HKTAB/DisplayTanGeneratorList). - chipTAN flicker codes (HHD 1.3 and 1.4, rendered to frames) and photoTAN/QR challenge images.
- Customer system ID synchronisation, and the TAN methods the user may use (
3920).
- The one-step method (
- The bank's messages and warnings for the app to show (
onBankMessage,onWarning), and new login data (3072) passed on. - Balances (
HKSAL6–8 /AcctBal1–2). - Transactions as camt.052 (
HKCAZ/AcctMvmtsSpecifiedPeriodCamt) or MT940/MT942 (HKKAZ6–7 /AcctMvmtsSpecifiedPeriod1–2), with automatic paging. - SEPA account details (
HKSPA/SEPAAcctInq) to fill in IBAN and BIC. A bank that rejects the list only leaves them out. - The account extension of the FinTS 3.0 user parameter data: BIC and complete balance without extra queries, and what the bank says about new data (
Account.dataStatus). - Only what the parameter data permit is sent. A transaction the user parameter data or
HIPINSrule out fails withFinTsUnsupportedExceptionbefore the bank is contacted. - Bank and user parameter data (BPD/UPD) are cached in an exportable state string.
- Parsers for MT940 (German structured
:86:fields and SEPA keywords) and camt.052/053.
Prerequisites #
- FinTS product registration. Since August 2019 German banks only accept registered client products. Register for free with the Deutsche Kreditwirtschaft at https://www.fints.org/de/hersteller/produktregistrierung (the ID arrives within 10–15 business days) and pass it as
productId. - Your bank's FinTS URL and bank code (BLZ).
Usage #
import 'package:fints/fints.dart';
// FinTs4Client instead for one of the few banks that run FinTS 4.1.
final client = FinTs3Client(
config: FinTsConfig(
url: Uri.parse('https://fints.example-bank.de/fints'),
bankCode: '12345678',
userId: 'my-login',
productId: 'YOUR_REGISTRATION_ID',
productVersion: '1.0',
),
pin: pin,
options: FinTsClientOptions(
state: savedState, // from a previous session, or null
tanMethodSelector: (methods) => showMethodPicker(methods),
tanMediumSelector: (media) => showMediumPicker(media),
onBankMessage: (message) => showNotice(message.subject, message.text),
onWarning: (warning) => showNotice(warning.code, warning.text), // banks want code and text shown
tanCallback: (challenge) async {
if (challenge.isDecoupled) {
// Close the dialog when challenge.resolved completes; offer "approved" only if challenge.manualConfirmation.
final confirmed = await showApprovalDialog(challenge);
return confirmed ? TanAnswer.approved : TanAnswer.cancelled; // approved: the client asks the bank at once
}
final tan = await askUserForTan(challenge); // show challenge.plainMessage, challenge.image or flickerCode
return tan == null ? TanAnswer.cancelled : TanAnswer.tan(tan);
},
),
);
try {
final accounts = await client.getAccounts();
final balance = await client.getBalance(accounts.first);
final transactions = await client.getTransactions(
accounts.first,
from: DateTime.now().subtract(const Duration(days: 90)),
);
} on FinTsPinException {
// wrong PIN or PIN locked; this client sends no PIN again, so create a new one to retry
} on FinTsLoginDataChangedException catch (e) {
// the bank has new login data for the user: store e.userId, e.customerId or e.loginName, use them from now on
} on FinTsTanException {
// invalid or expired TAN
} on FinTsBankException catch (e) {
// e.feedback holds all bank return codes
} finally {
savedState = client.state; // persist for the next session
await client.close();
}
One dialog per client. All calls on one client share a single bank dialog, so strong authentication happens once per session.
- A dialog idle for longer than the bank's maximum timeout (
BankInfo.maxTimeout, five minutes where it states none) is given up, and the next call logs in anew. - A dialog the bank ends otherwise (for example with return code
9800) fails the running call withFinTsDialogAbortedException, and the next call logs in anew. The client does not repeat the call by itself, since a new login may cost the user another approval. - Calls run one after another, so a callback (TAN, selector, bank message or warning) must not wait for another call of its own client. A call from inside the callback fails with a
StateError; a call from elsewhere, such as a button in the dialog the callback shows, runs once the current call is done.close()works from anywhere.
Protection against a locked access. Banks lock the access after a few failed PIN attempts, so once the bank has refused a login, the client never sends its PIN again. Every later call that would send it fails with the same exception, without contacting the bank; create a new client to try again.
- A login counts as refused for a rejected PIN (
FinTsPinException), wherever the bank reports it, and for any other error in the answer to the login (FinTsBankException), since the client cannot tell whether the PIN was the reason. In answer to a login,9941and9951mean an invalid signature, which in PIN/TAN is the PIN. The client does not even end the refused dialog, since that message would carry the PIN. - Not refused: a wrong or expired TAN (
FinTsTanException) or a rejected approval at login, since the bank checks the PIN before it asks for them, and an order the bank rejects in an open dialog, such as a TAN media list it does not offer the user. The next call logs in anew. - The client takes care of these reasons itself and logs in once more, once per reason and call: a TAN method the user may not use (
3920without the method, or9075after the one-step method), and a customer system ID the bank no longer accepts (9391). - New login data (
3072, for example a login name in place of user ID and customer ID) end the old ones. The client ends the dialog, as the specification asks, and the call and all later ones fail withFinTsLoginDataChangedException, which names the new data. Create a new client with them.
Bank messages and warnings. onBankMessage receives the bank's free-text messages and onWarning its warnings (return codes 3000 to 3999), such as 3951 when the user has to switch to another TAN method. The specification asks apps to show both, warnings with code and text. The warnings the client acts on itself do not reach onWarning: 3040 (more data follow), 3060 (some warnings), 3072 (new login data), 3076 (no strong authentication needed), 3920 (allowed TAN methods) and 3955 to 3957 (steps of an app approval).
Closing. close() also stops a running call, including one waiting for a TAN, an app approval or a selection; that call and all later ones throw a StateError. A request already on its way is awaited, at most for the transport's timeout.
Errors.
- All errors from the bank or the connection are
FinTsExceptions. AnotherExceptionof a custom transport is wrapped into one; anError, a bug in the transport, passes through. - Incomplete or malformed bank data raise
FinTsProtocolException; the client never fills in zeros or currencies, nor dates apart from one case (a camt entry that states its booking date only gets that as its value date), and never reads a refused order as an empty result. This includes a request for a TAN or approval without a challenge, more data announced without the position to continue at, and a query whose pages exceed 128 MiB (ask for a shorter period then). - A wrong TAN always fails the call with
FinTsTanException, also in the lookups that only complete data (SEPA account list, TAN media); a bank that rejects those lists only leaves out IBAN and BIC or the medium. Where the bank ends the dialog in answer to the SEPA account list, the call goes on without it, and the next call asks once more. - An error thrown by
onBankMessage,onWarningorloggerfails the call once its exchange with the bank is over, so that it cannot break off a dialog half-way. An error of the call itself takes precedence. - Invalid input fails with an
ArgumentErrorbefore the bank is contacted: an empty PIN, one with leading or trailing blanks, or one of a length the bank's parameters rule out; an empty login name, bank code or product ID, or a URL that is not https; a period whose start lies after its end; in FinTS 3.0, a PIN or login name with characters outside ISO 8859-1, such as€, and a product ID or version longer than 25 or 5 characters.
The TAN callback #
The callback returns a TanAnswer, synchronously or as a Future:
| Answer | Use |
|---|---|
TanAnswer.tan(tan) |
The TAN the user entered; an empty one cancels |
TanAnswer.approved |
The user reports the approval in the banking app (isDecoupled) |
TanAnswer.cancelled |
The user cancels |
An answer that does not fit the challenge, a TAN for an app approval or the other way round, fails the call with an ArgumentError. A cancel fails it with FinTsTanCancelledException, and the client ends the bank dialog.
TanChallenge exposes:
| Field | Use |
|---|---|
message |
Instruction text as the bank sent it |
plainMessage |
The instruction text as the user reads it: for methods with structured challenges the formatting tags of the specification are applied, other angle brackets stay |
image |
photoTAN / QR-TAN image (mimeType, bytes) |
flickerCode |
chipTAN optical code; frames() returns the 5-bar sequence to animate |
isDecoupled |
Approval happens on another device; answer TanAnswer.approved once the user reports it |
manualConfirmation |
false if the bank does not let the user report the approval: offer no confirm button |
resolved |
Set while the client polls the bank by itself; completes when the approval is through or has failed, so that you can close the prompt |
validUntil, tanMediumName, method |
Additional context; validUntil is an exact moment, see Dates and times |
App approvals. The bank's parameters decide who triggers the status requests:
- Normally the client polls by itself while the callback shows the instructions, and
resolvedcompletes once the bank confirms. A report of the user (TanAnswer.approved) makes it ask at once; while the bank has not confirmed yet, the callback is called again with the bank's latest instructions. - Where the bank forbids automatic status requests, or announces a push message (
3957, which a client without push channel cannot receive), the client asks after each report of the user. - Where the user cannot report the approval (
manualConfirmationisfalse), the client polls by itself in any case, since nothing else can complete the approval. AnswerTanAnswer.cancelledonly to cancel; any other answer is ignored.
The client follows the bank's timing within bounds against faulty parameters: status requests are at least 1 second and at most 1 minute apart, there are no more than the bank allows (any number where it says 0), and the client waits no more than 10 minutes in all.
Choosing the TAN method #
On the first login the client asks tanMethodSelector, and tanMediumSelector if the method needs a medium. client.selectedTanMethod and client.selectedTanMedium tell the current choice; await client.selectTanMethod('946') switches, and the next call logs in again with the new method. Methods the client cannot run are not offered: process variant 1, and methods that need an SMS charge account or an HHD_UC answer.
Where a method needs a TAN medium and the bank lists no active one for the user, the call fails with FinTsUnsupportedException before the login, which the bank would refuse. So it does where the bank refuses the one-step method for the synchronization of a new client and every method the user may use needs a medium, which the client can look up only after the synchronization. Pass the medium with selectTanMethod('942', tanMedium: …), or choose another method.
Asking only for what has changed #
FinTS 3.0 banks may describe each account in an extension of the user parameter data:
- The client takes the BIC from it, which spares the SEPA account list.
getBalanceanswers from it, without a query, where the bank marks the balance as complete. These data may come from the saved state; the bank has to raise their version whenever they change (Formals F.2), andBalance.bookedAsOftells the day the balance states.Account.dataStatuspasses on the rest: when the app may fetch transactions or the balance again. The rules, listed withAccountDataStatus, bind an app that stores transactions (Formals, chapter F), and banks may refuse an app that ignores them.
Persisting state #
client.state is a JSON string with the bank and user parameter data, the customer system ID, the chosen TAN method and medium, and the SEPA account list. Reusing it spares the synchronization and the TAN medium lookup on every start; a state saved for another user, customer ID, bank, server or protocol version is ignored. It never contains the PIN or TANs, but it does contain account details, so store it encrypted (for example with flutter_secure_storage).
The FinTS conditions of use forbid storing the PIN or TANs electronically, so pass the PIN in per session.
Dates and times #
A value is either a calendar date or an exact moment:
- A date without time of day, such as a booking date, is a local
DateTimeat midnight, of which only year, month and day count. A booking date the bank does not state isnull, not the value date. - A value with a time of day, such as
TanChallenge.validUntil, is an exact moment. Banks state it in German time; the client converts it into a localDateTimeof the device. SoBalance.bookedAsOfis a calendar date, andBalance.bookedAsOfTimethe exact moment where the bank states the time as well.
The period of getTransactions counts calendar days of from and to in local time, both included. A day after the bank's today, the current day in Germany, counts as that day, since banks reject future dates.
Logging #
logger: (direction, message) => ... receives every message, MessageDirection.outgoing or .incoming, with the PIN and TANs replaced by *** and the stated sizes adjusted, so that the log does not tell their length. The messages still contain account data, so log them for diagnosis only, and never to a place the user has not agreed to.
toString of Account, Balance, Transaction and TanMedium leaves out account data: it shows the last four characters of an IBAN, account or card number, and no amounts, names or purposes.
FinTS 4.1 #
FinTs4Client speaks the XML protocol, in namespace 4.1.1 by default; pass namespaces: FinTs4Namespaces.v41 for plain 4.1. FinTs4Client.queryVersions(...) runs the version inquiry in namespace 0.0.
The generated requests validate against the official XML schemas (release 2021-11-12) for every supported version of every business transaction, in both namespaces. test/v4_schema_test.dart checks this when FINTS4_SCHEMAS points to the unzipped schemas (4.1.1/ and 4.1/ from https://www.hbci-zka.de/spec/xmlschema/) and xmllint is installed. FINTS4_DUMP=<dir> writes the requests of a whole login out for other tools.
The product registration ID goes into ProductName, as into Produktbezeichnung in FinTS 3.0, and, base64 encoded, into ProductID.
App approval (decoupled) is a FinTS 3.0 feature: the 4.1 documents and schemas define neither it nor polling parameters. The client applies the same flow to a 4.1 bank that answers with 3955, with default timing: first check after 2 seconds, then every 2 seconds, at most 60 times.
Very few banks run FinTS 4.1, so this client is experimental: it is tested against simulated banks only. Use 3.0 unless your bank tells you otherwise.
Platform notes #
- Web builds cannot talk to banks directly, because bank servers send no CORS headers. Use a backend proxy or a native/desktop target.
- Only
httpsURLs are accepted.FinTsConfig.allowInsecureUrlallows plainhttpfor loopback addresses only (localhost,127.x.x.x,[::1]), to test against a local server. - The default
HttpTransportfollows no redirects, since the request carries the PIN; aborts a request after 90 seconds and rejects responses over 16 MiB (both constructor parameters); and followsHTTPS_PROXY/NO_PROXYon native platforms. For an authenticating proxy, passHttpTransport(client: IOClient(HttpClient()..findProxy = … ..addProxyCredentials(…)))(package:http/io_client.dart). - A custom
FinTsTransport(post(url, body, contentType: …)) can be passed asFinTsClientOptions.transport, for proxies, certificate pinning or test doubles. It should report failures asFinTsTransportException. The client closes its transport when it is closed, so give each client its own. - The client never sends a message larger than the bank's maximum (
BankInfo.maxMessageSize); it raisesFinTsProtocolExceptioninstead. - FinTS 3.0 uses ISO 8859-1. Some banks send UTF-8 nonetheless, for example in TAN medium names such as
Max’s iPhone. The client sends text in ISO 8859-1 wherever it fits, and UTF-8 only for text that does not, such as’. - A broken connection ends the dialog for the bank (Formals C.6); the client sends no dialog end then, and the next call logs in anew.
Testing #
The test suite runs against simulated banks (test/support) that were written from the specification. It does not replay recorded traffic of real banks, so a bank that deviates from the specification may fail in ways the tests do not show; please report such cases.
Scope #
This package reads data. It does not implement payments, securities, or the HBCI signature procedures (RAH/RDH chip cards and key files), because German consumer banks use PIN/TAN for FinTS access.
Example app #
example/ is a minimal Flutter app: pick a bank from the FinTS institute list, log in, approve with a TAN, and browse balances and transactions.
cp fints_secrets.example.json fints_secrets.json # once; put your product ID in it (git-ignored)
cd example
flutter run --dart-define-from-file=../fints_secrets.json
Development #
dart pub get
dart test
dart run tool/cli.dart # command-line demo; needs FINTS_URL, FINTS_BLZ, FINTS_USER (product ID from fints_secrets.json)
License #
BSD 3-Clause, see LICENSE. Commercial use is allowed. Apps that ship this library must reproduce the copyright notice and license text, for example on their open-source licenses screen. Flutter collects package licenses automatically; showLicensePage(context: context) displays them.