From 9850f2c1df06921ff3210235c6812cd9bca5b988 Mon Sep 17 00:00:00 2001 From: Moritz Andrich Date: Wed, 15 Jul 2026 09:25:59 +0200 Subject: [PATCH 1/3] Implement TAN support By sending messages between the haskell and python process for communication This is required for banks which do not allow querying transactions without providing a TAN --- README.md | 2 +- data/pyfints.py | 225 +++++++++++++++++++++++++++++++--------- src/Transactions.hs | 170 ++++++++++++++++++++++++++---- test/TransactionSpec.hs | 38 ++++++- test/files/fake-python | 11 ++ 5 files changed, 376 insertions(+), 70 deletions(-) create mode 100755 test/files/fake-python diff --git a/README.md b/README.md index 18f3adb..e994bca 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ The configuration file is located at `~/.config/fints2ledger/config.yml` (XDG co | `--files-path PATH` | Directory where fints2ledger stores its config files | — | | `-f`, `--journal-file FILE` | Path to the ledger journal file | `ledger.journalFile` | | `--date DATE` | Start date for fetching transactions (e.g. `25.01.2023`, `90 days ago`, `last monday`). Default: 90 days ago | — | -| `--python-command CMD` | Python executable to use. Default: `python3` | — | +| `--python-command PATH` | Python executable to use. Default: `python3` | — | | `--demo` | Run with sample transactions, without calling a FinTS endpoint | — | | `--config` | Open the config editor UI | — | | `--from-csv-file FILE` | Read transactions from a CSV file instead of a FinTS endpoint | — | diff --git a/data/pyfints.py b/data/pyfints.py index e4f0d68..2997e0f 100644 --- a/data/pyfints.py +++ b/data/pyfints.py @@ -1,74 +1,205 @@ -from fints.client import FinTS3PinTanClient +from fints.client import FinTS3PinTanClient, NeedTANResponse from mt940.models import Date -import os +import base64 +import hashlib import json +import os +import sys +import tempfile -def retrieve_transactions( - account, blz, password, endpoint, selected_account, start, end -): - client = FinTS3PinTanClient(blz, account, password, endpoint, product_id = "EC449295201FA9BE5040B9154") - return TRetriever(client, selected_account).get_hbci_transactions(start, end) +def send(message_type, **values): + print(json.dumps({"type": message_type, **values}), flush=True) -class TRetriever: - def __init__(self, client, accountnumber): - self.client = client - self.accountnumber = accountnumber - def get_hbci_transactions(self, start_date, end_date): - accounts = self.client.get_sepa_accounts() +def receive(expected_type): + line = sys.stdin.readline() + if not line: + raise RuntimeError("The fints2ledger process closed the protocol input") + message = json.loads(line) + if message.get("type") != expected_type: + raise RuntimeError( + f"Expected protocol message '{expected_type}', got {message.get('type')!r}" + ) + return message + + +def state_path(args): + identity = "\0".join((args["endpoint"], args["blz"], args["account"])) + digest = hashlib.sha256(identity.encode()).hexdigest()[:24] + return os.path.join(args["stateDirectory"], f"fints-state-{digest}.bin") + + +def load_state(path): + try: + with open(path, "rb") as reader: + return reader.read() + except FileNotFoundError: + return None + + +def store_state(path, data): + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + fd, temporary_path = tempfile.mkstemp(dir=os.path.dirname(path)) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "wb") as writer: + writer.write(data) + os.replace(temporary_path, path) + os.chmod(path, 0o600) + except BaseException: + try: + os.unlink(temporary_path) + except FileNotFoundError: + pass + raise + + +def choose_tan_settings(client): + if not client.get_current_tan_mechanism(): + client.fetch_tan_mechanisms() + mechanisms = list(client.get_tan_mechanisms().items()) + if not mechanisms: + # TAN-less banks do not advertise any mechanisms. There is no + # mechanism or medium to select, so continue with the dialog. + return + if len(mechanisms) == 1: + client.set_tan_mechanism(mechanisms[0][0]) + else: + send( + "tan_methods", + methods=[ + {"id": identifier, "name": mechanism.name} + for identifier, mechanism in mechanisms + ], + ) + selected = receive("tan_method")["id"] + if selected not in dict(mechanisms): + raise RuntimeError(f"Unknown TAN method selected: {selected!r}") + client.set_tan_mechanism(selected) + + if client.selected_tan_medium is None and client.is_tan_media_required(): + media = client.get_tan_media()[1] + if len(media) == 1: + client.set_tan_medium(media[0]) + elif len(media) == 0: + # Some banks demand a medium field but return no selectable media. + client.selected_tan_medium = "" + else: + send( + "tan_media", + media=[ + { + "index": index, + "name": medium.tan_medium_name, + "mobile": medium.mobile_number_masked, + } + for index, medium in enumerate(media) + ], + ) + selected = receive("tan_medium")["index"] + if not isinstance(selected, int) or not 0 <= selected < len(media): + raise RuntimeError(f"Unknown TAN medium selected: {selected!r}") + client.set_tan_medium(media[selected]) + + +def send_tan(client, response): + while isinstance(response, NeedTANResponse): + matrix = None + if response.challenge_matrix: + mime_type, image_data = response.challenge_matrix + matrix = { + "mimeType": mime_type, + "data": base64.b16encode(image_data).decode("ascii"), + } + + send( + "tan_challenge", + challenge=response.challenge or "A TAN is required", + decoupled=bool(response.decoupled), + hhduc=response.challenge_hhduc, + matrix=matrix, + ) + tan = receive("tan")["value"] + if not isinstance(tan, str): + raise RuntimeError("The TAN must be a string") + response = client.send_tan(response, tan) + return response - account = self.__find_matching_account(accounts, self.accountnumber) +class TransactionRetriever: + def __init__(self, client, account_number): + self.client = client + self.account_number = account_number + + def get_transactions(self, start_date, end_date): + accounts = self.client.get_sepa_accounts() + account = self.find_matching_account(accounts) return self.client.get_transactions(account, start_date, end_date) - def __find_matching_account(self, accounts, accountnumber): + def find_matching_account(self, accounts): for account in accounts: - if account.accountnumber == accountnumber: + if account.accountnumber == self.account_number: return account - raise Exception( - f"Could not find a matching account for account number '{accountnumber}'. Possible accounts: {accounts}" + raise RuntimeError( + f"Could not find account '{self.account_number}'. Possible accounts: {accounts}" ) -date_format="%Y/%m/%d" + +DATE_FORMAT = "%Y/%m/%d" + + def transaction_to_object(transaction): hbci_data = transaction.data - - date = hbci_data["date"].strftime(date_format) - amount = str(hbci_data["amount"].amount) - currency = hbci_data["amount"].currency - # GLS bank provides no "posting_text", "AdditionalEntryInformation" seems to be equivalent - posting_text = hbci_data.get("posting_text", hbci_data.get("AdditionalEntryInformation", None)) - applicant_name = hbci_data["applicant_name"] - purpose = hbci_data["purpose"] + posting_text = hbci_data.get( + "posting_text", hbci_data.get("AdditionalEntryInformation") + ) return { - "date": date, - "amount": amount, - "currency": currency, + "date": hbci_data["date"].strftime(DATE_FORMAT), + "amount": str(hbci_data["amount"].amount), + "currency": hbci_data["amount"].currency, "posting": (posting_text or "").strip(), - "payee": (applicant_name or "").strip(), - "purpose": (purpose or "").strip(), + "payee": (hbci_data["applicant_name"] or "").strip(), + "purpose": (hbci_data["purpose"] or "").strip(), } -def main(): - args = json.loads(os.environ["FINTS2LEDGER_ARGS"]) - transactions = retrieve_transactions( - account=args["account"], - blz=args["blz"], - password=args["password"], - endpoint=args["endpoint"], - selected_account=args["selectedAccount"], - start=date_string_to_mt940_date(args["start"]), - end=date_string_to_mt940_date(args["end"]), +def date_string_to_mt940_date(date_string): + year, month, day = date_string.split("/") + return Date(year=year, month=month, day=day) + + +def run(args): + path = state_path(args) + client = FinTS3PinTanClient( + args["blz"], + args["account"], + args["password"], + args["endpoint"], + product_id="EC449295201FA9BE5040B9154", + from_data=load_state(path), ) - converted = json.dumps(list(map(transaction_to_object, transactions))) - print(converted) + choose_tan_settings(client) + + with client: + client.init_tan_response = send_tan(client, client.init_tan_response) + result = TransactionRetriever(client, args["selectedAccount"]).get_transactions( + date_string_to_mt940_date(args["start"]), + date_string_to_mt940_date(args["end"]), + ) + result = send_tan(client, result) + transactions = [transaction_to_object(transaction) for transaction in result] + store_state(path, client.deconstruct(including_private=True)) + send("transactions", transactions=transactions) -def date_string_to_mt940_date(date_string): - parts = date_string.split("/") - return Date(year=parts[0], month=parts[1], day=parts[2]) + +def main(): + try: + run(receive("start")["arguments"]) + except Exception as exception: + send("error", message=str(exception)) + raise if __name__ == "__main__": diff --git a/src/Transactions.hs b/src/Transactions.hs index c5c4a29..8ad6b0d 100644 --- a/src/Transactions.hs +++ b/src/Transactions.hs @@ -13,14 +13,20 @@ module Transactions ( where import Config.AppConfig (AppConfig (..)) -import Config.Files (exampleFile, pyfintsFile) +import Config.Files (ConfigDirectory (..), exampleFile, pyfintsFile) import Config.YamlConfig (FintsConfig (..), Password (..)) import Control.Exception (Exception, throwIO) -import Data.Aeson (FromJSON, ToJSON) +import Control.Monad (forM_) +import Data.Aeson (FromJSON, ToJSON, (.:)) import Data.Aeson qualified as Aeson +import Data.Aeson.Types qualified as Aeson +import Data.ByteString qualified as StrictBS +import Data.ByteString.Base16 qualified as Base16 +import Data.ByteString.Char8 qualified as SBS import Data.ByteString.Lazy qualified as BS import Data.Csv (DefaultOrdered, FromField, FromNamedRecord, ToField, ToNamedRecord) import Data.Csv qualified as Csv +import Data.Function ((&)) import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding qualified as T @@ -32,10 +38,11 @@ import Data.Vector (toList) import GHC.Generics (Generic) import Hledger (getCurrentDay) import System.Console.Haskeline qualified as Haskeline -import System.IO (hFlush) +import System.Exit (ExitCode (..)) +import System.IO (Handle, hFlush, hIsEOF) import System.IO.Temp (withSystemTempFile) -import System.Process.Typed (ExitCode (ExitFailure, ExitSuccess), readProcess, shell) -import Utils (encodeAsString, formatDouble, orElseThrow, (??)) +import System.Process.Typed (createPipe, getStdin, getStdout, inherit, proc, setStderr, setStdin, setStdout, waitExitCode, withProcessWait) +import Utils (formatDouble, orElseThrow, (??)) getExampleTransactions :: IO [Transaction] getExampleTransactions = do @@ -70,31 +77,116 @@ getTransactionsFromFinTS config = do , password = password , start = formatDayForPython config.startDate , end = formatDayForPython currentDay + , stateDirectory = config.configDirectory.get } withSystemTempFile "fints2ledger.py" \path handle -> do TIO.hPutStr handle pyfintsFile hFlush handle - let shellCommand = - shell $ - "FINTS2LEDGER_ARGS='" - ++ encodeAsString pyfintsArgs - ++ "' " - ++ config.pythonExecutable - ++ " " - ++ path - (exitCode, stdOut, stdErr) <- readProcess shellCommand - case exitCode of - ExitSuccess -> Aeson.eitherDecode stdOut `orElseThrow` TransactionDecodeError - ExitFailure _ -> do - TIO.putStrLn $ TL.toStrict $ TL.decodeUtf8 stdErr - throwIO $ PyFintsError "Failed to get FinTS transactions, check the message above." + let processConfig = + proc config.pythonExecutable [path] + & setStdin createPipe + & setStdout createPipe + & setStderr inherit + withProcessWait processConfig \process -> do + sendMessage (getStdin process) $ Aeson.object ["type" Aeson..= ("start" :: Text), "arguments" Aeson..= pyfintsArgs] + result <- handleMessages (getStdin process) (getStdout process) + exitCode <- waitExitCode process + case (exitCode, result) of + (ExitSuccess, Right transactions) -> return transactions + (_, Left message) -> throwIO $ PyFintsError $ T.unpack message + (ExitFailure _, Right _) -> throwIO $ PyFintsError "The FinTS process failed, check the message above." + +sendMessage :: (ToJSON message) => Handle -> message -> IO () +sendMessage handle message = do + BS.hPutStr handle $ Aeson.encode message <> "\n" + hFlush handle + +handleMessages :: Handle -> Handle -> IO (Either Text [Transaction]) +handleMessages input output = do + eof <- hIsEOF output + if eof + then return $ Left "The FinTS process ended without returning transactions." + else do + line <- SBS.hGetLine output + case Aeson.eitherDecodeStrict line of + Left message -> throwIO $ PyFintsError $ "Invalid message from FinTS process: " <> message + Right workerMessage -> case workerMessage of + TanMethods methods -> do + selected <- promptForChoice "TAN method" (map (\method -> (method.identifier, method.name)) methods) + sendMessage input $ Aeson.object ["type" Aeson..= ("tan_method" :: Text), "id" Aeson..= selected] + handleMessages input output + TanMedia media -> do + selected <- promptForChoice "TAN medium" (map (\medium -> (medium.index, mediumLabel medium)) media) + sendMessage input $ Aeson.object ["type" Aeson..= ("tan_medium" :: Text), "index" Aeson..= selected] + handleMessages input output + TanChallenge challenge -> do + tanValue <- promptForTan challenge + sendMessage input $ Aeson.object ["type" Aeson..= ("tan" :: Text), "value" Aeson..= tanValue] + handleMessages input output + Transactions transactions -> return $ Right transactions + WorkerError message -> return $ Left message + +promptForTan :: TanChallengeDetails -> IO Text +promptForTan challenge = do + TIO.putStrLn challenge.message + case challenge.hhduc of + Just hhduc -> TIO.putStrLn $ "chipTAN challenge data: " <> hhduc + Nothing -> return () + case challenge.matrix of + Just matrix -> withPhotoTanFile matrix askForTan + Nothing -> askForTan + where + askForTan + | challenge.decoupled = do + Haskeline.runInputT Haskeline.defaultSettings $ Haskeline.getInputLine "Approve the request in your banking app, then press Enter to check again." + return "" + | otherwise = do + Password tanValue <- getSecret "TAN: " + return tanValue + +withPhotoTanFile :: TanMatrix -> IO value -> IO value +withPhotoTanFile matrix action = + withSystemTempFile ("fints2ledger-photo-tan" <> matrixExtension matrix.mimeType) \path handle -> do + case Base16.decode $ T.encodeUtf8 matrix.encodedData of + Left message -> throwIO $ PyFintsError $ "Invalid photoTAN image: " <> message + Right image -> StrictBS.hPut handle image + hFlush handle + TIO.putStrLn $ "photoTAN image: " <> T.pack path + action + +matrixExtension :: Text -> String +matrixExtension "image/png" = ".png" +matrixExtension "image/jpeg" = ".jpg" +matrixExtension _ = ".img" + +promptForChoice :: Text -> [(identifier, Text)] -> IO identifier +promptForChoice label choices = do + TIO.putStrLn $ "Available " <> label <> "s:" + forM_ (zip [(1 :: Int) ..] choices) \(number, (_, description)) -> + TIO.putStrLn $ " " <> T.pack (show number) <> ". " <> description + Haskeline.runInputT Haskeline.defaultSettings ask + where + ask = do + answer <- Haskeline.getInputLine $ T.unpack $ label <> " (number): " + case answer >>= readChoice of + Just choice -> return $ fst $ choices !! (choice - 1) + Nothing -> Haskeline.outputStrLn "Please enter one of the numbers shown above." >> ask + readChoice value = case reads value of + [(choice, "")] | choice >= 1 && choice <= length choices -> Just choice + _ -> Nothing + +mediumLabel :: TanMedium -> Text +mediumLabel medium = medium.name <> maybe "" (\mobile -> " (" <> mobile <> ")") medium.mobile getPassword :: IO Password -getPassword = do +getPassword = getSecret "Banking Password: " + +getSecret :: String -> IO Password +getSecret prompt = do Haskeline.runInputT Haskeline.defaultSettings do - maybePassword <- Haskeline.getPassword (Just '*') "Banking Password: " + maybePassword <- Haskeline.getPassword (Just '*') prompt Haskeline.outputStrLn "" return $ Password $ T.pack (maybePassword ?? "") @@ -109,9 +201,45 @@ data PyFintsArguments = PyFintsArguments , password :: Password , start :: String , end :: String + , stateDirectory :: FilePath } deriving (Generic, ToJSON) +data TanMethod = TanMethod {identifier :: Text, name :: Text} +data TanMedium = TanMedium {index :: Int, name :: Text, mobile :: Maybe Text} +data TanMatrix = TanMatrix {mimeType :: Text, encodedData :: Text} +data TanChallengeDetails = TanChallengeDetails {message :: Text, decoupled :: Bool, hhduc :: Maybe Text, matrix :: Maybe TanMatrix} + +data WorkerMessage + = TanMethods [TanMethod] + | TanMedia [TanMedium] + | TanChallenge TanChallengeDetails + | Transactions [Transaction] + | WorkerError Text + +instance FromJSON TanMethod where + parseJSON = Aeson.withObject "TAN method" \object -> TanMethod <$> object .: "id" <*> object .: "name" + +instance FromJSON TanMedium where + parseJSON = Aeson.withObject "TAN medium" \object -> TanMedium <$> object .: "index" <*> object .: "name" <*> object .: "mobile" + +instance FromJSON TanMatrix where + parseJSON = Aeson.withObject "TAN matrix" \object -> TanMatrix <$> object .: "mimeType" <*> object .: "data" + +instance FromJSON TanChallengeDetails where + parseJSON = Aeson.withObject "TAN challenge" \object -> TanChallengeDetails <$> object .: "challenge" <*> object .: "decoupled" <*> object .: "hhduc" <*> object .: "matrix" + +instance FromJSON WorkerMessage where + parseJSON = Aeson.withObject "FinTS worker message" \object -> do + messageType <- object .: "type" :: Aeson.Parser Text + case messageType of + "tan_methods" -> TanMethods <$> object .: "methods" + "tan_media" -> TanMedia <$> object .: "media" + "tan_challenge" -> TanChallenge <$> object .: "challenge" + "transactions" -> Transactions <$> object .: "transactions" + "error" -> WorkerError <$> object .: "message" + unknown -> fail $ "Unknown FinTS worker message: " <> T.unpack unknown + newtype Amount = Amount {amount :: Double} deriving newtype (Num, Show, Eq, FromField) diff --git a/test/TransactionSpec.hs b/test/TransactionSpec.hs index 7c440d7..660ec90 100644 --- a/test/TransactionSpec.hs +++ b/test/TransactionSpec.hs @@ -1,11 +1,15 @@ module TransactionSpec (spec) where +import Config.AppConfig (AppConfig (..)) import Config.Files (exampleFile) +import Config.YamlConfig (FintsConfig (..), LedgerConfig (..), Password (..)) import Data.Aeson qualified as Aeson +import Data.Map qualified as Map import Data.Text.Lazy (fromStrict) import Data.Text.Lazy.Encoding (encodeUtf8) +import Data.Time (fromGregorian) import Test.Syd (Spec, describe, it, shouldBe, shouldContain) -import Transactions (Amount (Amount), Transaction (..), transactionsToCsv) +import Transactions (Amount (Amount), Transaction (..), getTransactionsFromFinTS, transactionsToCsv) import Utils (byteStringToString) spec :: Spec @@ -37,3 +41,35 @@ spec = do } byteStringToString (transactionsToCsv [transaction]) `shouldContain` "0.01" + + it "exchanges transaction messages with the Python worker" do + transactions <- getTransactionsFromFinTS protocolTestConfig + transactions + `shouldBe` [ + Transaction + { date = "2022/03/23" + , amount = Amount 0.01 + , currency = "EUR" + , posting = "Test" + , payee = "Test Payee" + , purpose = "Protocol test" + } + ] + +protocolTestConfig :: AppConfig +protocolTestConfig = + Config + { fintsConfig = + FintsConfig + { account = "user" + , blz = "12345678" + , endpoint = "https://example.invalid" + , selectedAccount = Nothing + , password = Just $ Password "secret" + } + , ledgerConfig = LedgerConfig Map.empty [] Nothing [] [] + , configDirectory = "/tmp/fints2ledger-protocol-test" + , journalFile = "/tmp/fints2ledger-protocol-test.ledger" + , startDate = fromGregorian 2022 3 1 + , pythonExecutable = "test/files/fake-python" + } diff --git a/test/files/fake-python b/test/files/fake-python new file mode 100755 index 0000000..07a590a --- /dev/null +++ b/test/files/fake-python @@ -0,0 +1,11 @@ +#!/bin/sh +IFS= read -r request +case "$request" in + *'"type":"start"'*) + printf '%s\n' '{"type":"transactions","transactions":[{"date":"2022/03/23","amount":"0.01","currency":"EUR","posting":"Test","payee":"Test Payee","purpose":"Protocol test"}]}' + ;; + *) + printf '%s\n' '{"type":"error","message":"Expected start message"}' + exit 1 + ;; +esac From c83c0a87cdfffe4992280609b67a3b31d7b0f689 Mon Sep 17 00:00:00 2001 From: Moritz Andrich Date: Sun, 16 Aug 2026 09:41:36 +0200 Subject: [PATCH 2/3] Update devenv --- devenv.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/devenv.lock b/devenv.lock index 07af2c8..6146066 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,11 +3,11 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1782492839, - "narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=", + "lastModified": 1786811115, + "narHash": "sha256-wKOWSJbR5fQtVaw8GicEHI1g0oQD1d9kwnRxYe549ug=", "owner": "cachix", "repo": "devenv", - "rev": "3d39d0817d62069f7b18821c34a617b5141cb278", + "rev": "07758eec4c965af6c95cf076c9fd2d219bc6b9c9", "type": "github" }, "original": { @@ -22,11 +22,11 @@ "nixpkgs-src": "nixpkgs-src" }, "locked": { - "lastModified": 1782132010, - "narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=", + "lastModified": 1786472144, + "narHash": "sha256-W1wLGKZKGtnTWGRFgjbsVQleuO6jgMweaSAbFltLkN0=", "owner": "cachix", "repo": "devenv-nixpkgs", - "rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89", + "rev": "ea21d30f66a051c7cd750692a2af8d56e0ec4bfb", "type": "github" }, "original": { @@ -39,11 +39,11 @@ "nixpkgs-src": { "flake": false, "locked": { - "lastModified": 1781607440, - "narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", + "lastModified": 1786098110, + "narHash": "sha256-shi1tjDhCGGd0kIgVPNY03V8NBWSTzhMSFDb7IqSoec=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", + "rev": "afb4584a80bbf779ce0f691509ff902d188c2b3d", "type": "github" }, "original": { From ae0dc8cffe4015913f28c99cb969d072a1a8fad4 Mon Sep 17 00:00:00 2001 From: Moritz Andrich Date: Sun, 16 Aug 2026 10:46:01 +0200 Subject: [PATCH 3/3] Display photoTAN in the terminal and also support a bit of manual testing via SIMULATED_TAN_METHODS env variable --- data/pyfints.py | 33 ++++++++++++++++++++++++++++ src/Transactions.hs | 52 +++++++++++++++++++++++++++++++++------------ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/data/pyfints.py b/data/pyfints.py index 2997e0f..a51eef2 100644 --- a/data/pyfints.py +++ b/data/pyfints.py @@ -7,6 +7,14 @@ import sys import tempfile +SIMULATED_TAN_METHODS = [ + {"id": "900", "name": "Simulated pushTAN"}, + {"id": "901", "name": "Simulated photoTAN"}, +] +SIMULATED_PHOTOTAN_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) + def send(message_type, **values): print(json.dumps({"type": message_type, **values}), flush=True) @@ -56,6 +64,30 @@ def store_state(path, data): def choose_tan_settings(client): + if os.environ.get("FINTS2LEDGER_SIMULATE_TAN") == "1": + send("tan_methods", methods=SIMULATED_TAN_METHODS) + selected = receive("tan_method")["id"] + if selected not in {method["id"] for method in SIMULATED_TAN_METHODS}: + raise RuntimeError(f"Unknown simulated TAN method selected: {selected!r}") + if selected == "901": + send( + "tan_challenge", + challenge="Simulated photoTAN challenge", + decoupled=False, + hhduc=None, + matrix={ + "mimeType": "image/png", + "data": base64.b16encode(SIMULATED_PHOTOTAN_PNG).decode("ascii"), + "base64Data": base64.b64encode(SIMULATED_PHOTOTAN_PNG).decode( + "ascii" + ), + }, + ) + receive("tan") + # The simulated method is deliberately not passed to pyfints: the bank + # did not advertise it and would reject it. Continue as a TAN-less bank. + return + if not client.get_current_tan_mechanism(): client.fetch_tan_mechanisms() mechanisms = list(client.get_tan_mechanisms().items()) @@ -111,6 +143,7 @@ def send_tan(client, response): matrix = { "mimeType": mime_type, "data": base64.b16encode(image_data).decode("ascii"), + "base64Data": base64.b64encode(image_data).decode("ascii"), } send( diff --git a/src/Transactions.hs b/src/Transactions.hs index 8ad6b0d..b5ff82e 100644 --- a/src/Transactions.hs +++ b/src/Transactions.hs @@ -16,7 +16,7 @@ import Config.AppConfig (AppConfig (..)) import Config.Files (ConfigDirectory (..), exampleFile, pyfintsFile) import Config.YamlConfig (FintsConfig (..), Password (..)) import Control.Exception (Exception, throwIO) -import Control.Monad (forM_) +import Control.Monad (forM_, when) import Data.Aeson (FromJSON, ToJSON, (.:)) import Data.Aeson qualified as Aeson import Data.Aeson.Types qualified as Aeson @@ -39,7 +39,7 @@ import GHC.Generics (Generic) import Hledger (getCurrentDay) import System.Console.Haskeline qualified as Haskeline import System.Exit (ExitCode (..)) -import System.IO (Handle, hFlush, hIsEOF) +import System.IO (Handle, hFlush, hIsEOF, stdout) import System.IO.Temp (withSystemTempFile) import System.Process.Typed (createPipe, getStdin, getStdout, inherit, proc, setStderr, setStdin, setStdout, waitExitCode, withProcessWait) import Utils (formatDouble, orElseThrow, (??)) @@ -154,8 +154,32 @@ withPhotoTanFile matrix action = Right image -> StrictBS.hPut handle image hFlush handle TIO.putStrLn $ "photoTAN image: " <> T.pack path + when (matrix.mimeType == "image/png") $ displayKittyPng matrix.base64Data action +-- The Kitty graphics protocol uses base64 payload chunks of at most 4096 +-- bytes. Unsupported terminals ignore the APC escape sequences, while the +-- temporary file path above remains available as a fallback. +displayKittyPng :: Text -> IO () +displayKittyPng encodedImage = do + forM_ (zip [(0 :: Int) ..] chunks) \(index, chunk) -> do + let firstChunk = index == 0 + finalChunk = index == length chunks - 1 + metadata = if firstChunk then "a=T,f=100,c=40,q=2," else "q=2," + moreData = if finalChunk then "0" else "1" + TIO.putStr $ "\x1b_G" <> metadata <> "m=" <> moreData <> ";" <> chunk <> "\x1b\\" + TIO.putStrLn "" + hFlush stdout + where + chunks = chunksOf 4096 encodedImage + +chunksOf :: Int -> Text -> [Text] +chunksOf size text + | T.null text = [] + | otherwise = chunk : chunksOf size rest + where + (chunk, rest) = T.splitAt size text + matrixExtension :: Text -> String matrixExtension "image/png" = ".png" matrixExtension "image/jpeg" = ".jpg" @@ -207,7 +231,7 @@ data PyFintsArguments = PyFintsArguments data TanMethod = TanMethod {identifier :: Text, name :: Text} data TanMedium = TanMedium {index :: Int, name :: Text, mobile :: Maybe Text} -data TanMatrix = TanMatrix {mimeType :: Text, encodedData :: Text} +data TanMatrix = TanMatrix {mimeType :: Text, encodedData :: Text, base64Data :: Text} data TanChallengeDetails = TanChallengeDetails {message :: Text, decoupled :: Bool, hhduc :: Maybe Text, matrix :: Maybe TanMatrix} data WorkerMessage @@ -224,21 +248,23 @@ instance FromJSON TanMedium where parseJSON = Aeson.withObject "TAN medium" \object -> TanMedium <$> object .: "index" <*> object .: "name" <*> object .: "mobile" instance FromJSON TanMatrix where - parseJSON = Aeson.withObject "TAN matrix" \object -> TanMatrix <$> object .: "mimeType" <*> object .: "data" + parseJSON = Aeson.withObject "TAN matrix" \object -> TanMatrix <$> object .: "mimeType" <*> object .: "data" <*> object .: "base64Data" instance FromJSON TanChallengeDetails where parseJSON = Aeson.withObject "TAN challenge" \object -> TanChallengeDetails <$> object .: "challenge" <*> object .: "decoupled" <*> object .: "hhduc" <*> object .: "matrix" instance FromJSON WorkerMessage where - parseJSON = Aeson.withObject "FinTS worker message" \object -> do - messageType <- object .: "type" :: Aeson.Parser Text - case messageType of - "tan_methods" -> TanMethods <$> object .: "methods" - "tan_media" -> TanMedia <$> object .: "media" - "tan_challenge" -> TanChallenge <$> object .: "challenge" - "transactions" -> Transactions <$> object .: "transactions" - "error" -> WorkerError <$> object .: "message" - unknown -> fail $ "Unknown FinTS worker message: " <> T.unpack unknown + parseJSON value = Aeson.withObject "FinTS worker message" parseMessage value + where + parseMessage object = do + messageType <- object .: "type" :: Aeson.Parser Text + case messageType of + "tan_methods" -> TanMethods <$> object .: "methods" + "tan_media" -> TanMedia <$> object .: "media" + "tan_challenge" -> TanChallenge <$> Aeson.parseJSON value + "transactions" -> Transactions <$> object .: "transactions" + "error" -> WorkerError <$> object .: "message" + unknown -> fail $ "Unknown FinTS worker message: " <> T.unpack unknown newtype Amount = Amount {amount :: Double} deriving newtype (Num, Show, Eq, FromField)