From 5987b71993c74b21fca4ad1b7e05d1f5f2abdf22 Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Tue, 10 Nov 2026 23:52:22 +0100 Subject: [PATCH] be: read csob slovakia's sepa credit from public samples, the first slovak bank --- .docs/banks/README.md | 2 +- .docs/banks/sk/csob/README.md | 36 +++++ README.md | 2 +- .../fixtures/csob-sk/incoming-synthetic.eml | 21 +++ .../cz/insuit/bank/notification/CsobSk.java | 126 ++++++++++++++++++ .../insuit/bank/notification/CsobSkTest.java | 113 ++++++++++++++++ .../bank/notification/BankNotifications.java | 3 +- 7 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 .docs/banks/sk/csob/README.md create mode 100644 modules/bank-notifications/src/banks/sk/csob/fixtures/csob-sk/incoming-synthetic.eml create mode 100644 modules/bank-notifications/src/banks/sk/csob/java/cz/insuit/bank/notification/CsobSk.java create mode 100644 modules/bank-notifications/src/banks/sk/csob/test/cz/insuit/bank/notification/CsobSkTest.java diff --git a/.docs/banks/README.md b/.docs/banks/README.md index 0776086..e769c6d 100644 --- a/.docs/banks/README.md +++ b/.docs/banks/README.md @@ -34,7 +34,7 @@ A bank is in one of three states: | Bank | State | Reads | Leaves out | Details | |---|---|---|---|---| | Fio banka | not yet | | | [#10](https://github.com/landsman/bank-java/issues/10) | -| ČSOB | not yet | | | [#22](https://github.com/landsman/bank-java/issues/22) | +| ČSOB | samples only | incoming SEPA credits; IBANs, name, symbols from the payer's reference, message | outgoing payments, transaction id; the 2019 sample was not DKIM-signed and `csob.sk` has no DNSSEC | [csob](sk/csob/README.md) | | Slovenská sporiteľňa | not yet | | | [#23](https://github.com/landsman/bank-java/issues/23) | | Tatra banka | not yet | | key known, still published | [#24](https://github.com/landsman/bank-java/issues/24) | diff --git a/.docs/banks/sk/csob/README.md b/.docs/banks/sk/csob/README.md new file mode 100644 index 0000000..8a8608a --- /dev/null +++ b/.docs/banks/sk/csob/README.md @@ -0,0 +1,36 @@ +# How ČSOB Slovakia's e-mail behaves + +**Not seen as a real signed e-mail yet (2026-11-10).** The parser follows +[tomaj/bank-mails-parser](https://github.com/tomaj/bank-mails-parser/tree/master/tests/fixtures/csob-sk)'s 16 fixtures +and [remp2020's raw e-mail](https://github.com/remp2020/crm-payments-module/blob/master/docs/sk_csob.eml) (both MIT, +2019). **That e-mail carries no DKIM signature of the bank** (only Gmail's own ARC headers), and `csob.sk` is not +DNSSEC-signed (checked 2026-11-10 through 1.1.1.1). So no key is pinned, and even with one `withDns()` would refuse every +ČSOB SK e-mail until a real one shows that ČSOB SK signs, and under which domain. See +[#22](https://github.com/landsman/bank-java/issues/22). + +## What it carries + +Subject `ČSOB Info 24 - Avízo`, **windows-1250**, base64: + +``` +dovoľujeme si Vám oznámiť, že dňa 6.9.2019 bola na účte SK13 7500 0000 0040 1942 5381 PETIT PRESS, A.S. zaúčtovaná suma SEPA platobného príkazu: +suma: +1150,00 EUR +z účtu: SK91 7500 0000 0030 2072 9058 +banka: CEKOSKBX +detaily platby: +názov protiúčtu: NOVAK JOZEF +referencia platiteľa: /VS212049000/SS964/KS0308 +informácia pre príjemcu: Maroko 2019 + +Zostatok na účte po zaúčtovaní sumy platobnej operácie: +14050,70 EUR +``` + +- **Accounts are IBANs**, read with `BankAccount.parseIban` and checked against both modulo 97 and modulo 11. +- **Only incoming payments**: no outgoing sample has been seen, so an amount with a minus is refused. +- **The symbols come from the payer's reference** (`/VS…/SS…/KS…`), text the payer wrote: match on the amount as well. + `NOTPROVIDED` means none. The template prints the line every time, so it is required exactly once, and it has to + follow the counter-party's name directly; a reference forged in the name makes a second. +- **Past `informácia pre príjemcu`**, the payer's message, no labelled line may follow, and a second booking sentence is + refused: tomaj's attack fixtures put a forged account or a whole forged transaction there. +- **No transaction id**; the balance has no time. No thousands separator in the samples (`+1150,00`). +- tomaj's multi-transaction fixture is synthetic, so one transaction per e-mail is read. diff --git a/README.md b/README.md index 551f96c..ed4a272 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ For Java 21+ and Kotlin, one jar, no framework required. ## Which banks -Air Bank and Fio banka CZ from real e-mails; MONETA, Creditas, Raiffeisenbank, UniCredit and ČSOB from public samples, until a real +Air Bank and Fio banka CZ from real e-mails; MONETA, Creditas, Raiffeisenbank, UniCredit and ČSOB, and ČSOB Slovakia, from public samples, until a real e-mail confirms them. Each bank, and what it reads: [banks](.docs/banks/README.md). ## Docs diff --git a/modules/bank-notifications/src/banks/sk/csob/fixtures/csob-sk/incoming-synthetic.eml b/modules/bank-notifications/src/banks/sk/csob/fixtures/csob-sk/incoming-synthetic.eml new file mode 100644 index 0000000..ff980d5 --- /dev/null +++ b/modules/bank-notifications/src/banks/sk/csob/fixtures/csob-sk/incoming-synthetic.eml @@ -0,0 +1,21 @@ +Date: Fri, 06 Sep 2019 14:09:38 +0200 +From: =?windows-1250?q?=C8SOB?= +To: bruce.wayne@gothba.org +Message-ID: <0000@csob.sk> +Subject: =?windows-1250?q?=C8SOB_Info_24_-_Av=EDzo?= +MIME-Version: 1.0 +Content-Type: text/plain; charset="windows-1250" +Content-Transfer-Encoding: base64 + +VuGeZW7hIGtsaWVudGthLCB24Z5lbv0ga2xpZW50LA0KDQpkb3ZvvnVqZW1lIHNpIFbhbSBvem7h +bWmdLCCeZSBk8mEgNi45LjIwMTkgYm9sYSBuYSD66HRlIFNLMTMgNzUwMCAwMDAwIDAwNDAgMTk0 +MiA1MzgxIFdBWU5FIEVOVEVSUFJJU0VTLCBBLlMuIHph+uh0b3ZhbuEgc3VtYSBTRVBBIHBsYXRv +Ym7paG8gcHLta2F6dToNCnN1bWE6ICAgICAgICAgICAgICAgICAgICArMTE1MCwwMCBFVVINCnog ++uh0dTogICAgICAgICAgICAgICAgICBTSzc1IDA5MDAgMDAwMCAwMDEyIDM0NTYgNzg5OQ0KYmFu +a2E6ICAgICAgICAgICAgICAgICAgIEdJQkFTS0JYDQpkZXRhaWx5IHBsYXRieToNCm7hem92IHBy +b3Rp+uh0dTogICAgICAgICBQRU5OWVdPUlRIIEFMRlJFRA0KcmVmZXJlbmNpYSBwbGF0aXRlvmE6 +ICAgIC9WUzIxMjA0OTAwMC9TUzk2NC9LUzAzMDgNCmluZm9ybeFjaWEgcHJlIHBy7WplbWN1OiBC +YXRtb2JpbCAyMDE5DQoNCg0KDQpab3N0YXRvayBuYSD66HRlIHBvIHph+uh0b3Zhbu0gc3VteSBw +bGF0b2JuZWogb3BlcuFjaWU6ICsxNDA1MCw3MCBFVVINCg0KVGVudG8gZS1tYWlsIGplIGdlbmVy +b3Zhbv0gYXV0b21hdGlja3ksIHByb3PtbWUsIG5lb2Rwb3ZlZGFqdGUgbmHyLg0KDQrPYWt1amVt +ZSB6YSB2eXWeaXRpZSBzbHWeaWViIMhTT0IgSW5mbyAyNCwNCg0KyFNPQi4NCg== diff --git a/modules/bank-notifications/src/banks/sk/csob/java/cz/insuit/bank/notification/CsobSk.java b/modules/bank-notifications/src/banks/sk/csob/java/cz/insuit/bank/notification/CsobSk.java new file mode 100644 index 0000000..2af99fc --- /dev/null +++ b/modules/bank-notifications/src/banks/sk/csob/java/cz/insuit/bank/notification/CsobSk.java @@ -0,0 +1,126 @@ +package cz.insuit.bank.notification; + +import cz.insuit.bank.notification.BankNotificationException.Unparsed; +import cz.insuit.bank.notification.Transaction.Direction; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Currency; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * ČSOB Slovakia's "ČSOB Info 24 - Avízo", from {@code csob.sk}, in windows-1250: a SEPA credit booked to the account. + * + *

Read from public samples, not from a real signed e-mail: the template follows + * tomaj/bank-mails-parser's fixtures and + * remp2020/crm-payments-module's raw e-mail (both MIT, 2019). + * That e-mail carries no DKIM signature of the bank, and {@code csob.sk} is not DNSSEC-signed (2026-11-10), so no key is + * pinned and {@link BankNotifications#withDns()} refuses every ČSOB SK e-mail as unverified; a real one has to show + * whether ČSOB SK signs at all. + * + *

Only incoming payments are read: no outgoing one has been seen, so an amount with a minus is refused. The symbols + * come from the payer's reference ({@code /VS…/SS…/KS…}), text the payer wrote. The template prints that line every time, + * {@code NOTPROVIDED} when there is none, so it is required exactly once, and it has to follow the counter-party's name + * directly; past the message for the recipient no labelled line may follow. + */ +final class CsobSk implements Bank { + + static final String DOMAIN = "csob.sk"; + + /** None: no signed e-mail seen. See {@link AirBank#KEY_FINGERPRINTS} for how keys are kept. */ + static final Set KEY_FINGERPRINTS = Set.of(); + + @Override + public String domain() { + return DOMAIN; + } + + @Override + public Set keyFingerprints() { + return KEY_FINGERPRINTS; + } + + @Override + public Transaction parse(@Nullable String subject, String body) { + NotificationLines lines = new NotificationLines(body); + Matcher sentence = lines.single(SENTENCE, "booking sentence"); + if (sentence == null) throw new Unparsed("no booking sentence"); + if (lines.single(REFERENCE_LINE, "payer's reference") == null) + throw new Unparsed("no referencia platiteľa line"); + // The counter-party's name is text its holder chose; the payer's reference comes right after it. + lines.requireNext(NAME_LINE, REFERENCE + ":"); + // Past the message for the recipient only the balance line follows, which is not a labelled one. + Map fields = lines.labelled(LABELLED, Set.of(MESSAGE), Set.of()); + + String amountText = fields.get(AMOUNT); + Matcher amount = amountText == null ? null : MONEY.matcher(amountText); + if (amount == null || !amount.matches()) throw new Unparsed("suma missing or not in the known format"); + if (!amount.group(1).equals("+")) throw new Unparsed("an outgoing payment, not seen in a sample yet"); + + LocalDate bookedOn; + try { + bookedOn = LocalDate.parse(sentence.group(1), DATE); + } catch (DateTimeParseException e) { + throw new Unparsed("date not d.M.yyyy: " + sentence.group(1)); + } + Matcher reference = SYMBOLS.matcher(fields.getOrDefault(REFERENCE, "")); + boolean symbols = reference.matches(); + String counter = fields.get(COUNTER_ACCOUNT); + try { + return new Transaction( + DOMAIN, + Direction.INCOMING, + Transaction.Type.TRANSFER, + BankAccount.parseIban(sentence.group(2)), + counter == null ? null : BankAccount.parseIban(counter), + fields.get(COUNTER_NAME), + new Money( + new BigDecimal(amount.group(2).replace(" ", "") + "." + amount.group(3)), + Currency.getInstance(amount.group(4))), + symbols + ? new PaymentSymbols( + PaymentSymbols.checked("VS", empty(reference.group(1))), + PaymentSymbols.checked("KS", empty(reference.group(3))), + PaymentSymbols.checked("SS", empty(reference.group(2)))) + : PaymentSymbols.NONE, + fields.get(MESSAGE), + bookedOn, + null, + // The balance line has no time; the date is all there is. + null, + null); + } catch (IllegalArgumentException e) { + throw new Unparsed(e.getMessage() == null ? "invalid value" : e.getMessage(), e); + } + } + + private static @Nullable String empty(String symbol) { + return symbol.isEmpty() ? null : symbol; + } + + // The account holder's name follows the IBAN; it is the bank's record, not the payer's. + private static final Pattern SENTENCE = Pattern.compile( + "dovoľujeme si Vám oznámiť, že dňa (\\d{1,2}\\.\\d{1,2}\\.\\d{4}) bola na účte (SK\\d{2}(?: \\d{4}){5}) .*" + + " zaúčtovaná suma SEPA platobného príkazu:"); + private static final Pattern LABELLED = Pattern.compile( + "(suma|z účtu|banka|názov protiúčtu|referencia platiteľa|informácia pre príjemcu):\\s*(.*)", + Pattern.DOTALL); + private static final Pattern REFERENCE_LINE = Pattern.compile("referencia platiteľa:.*", Pattern.DOTALL); + private static final Pattern NAME_LINE = Pattern.compile("názov protiúčtu:.*", Pattern.DOTALL); + // The payer's reference, "/VS212049000/SS964/KS0308"; each symbol may be empty. + private static final Pattern SYMBOLS = Pattern.compile("/VS(\\d{0,10})/SS(\\d{0,10})/KS(\\d{0,10})"); + // A sign, no separator between thousands in the samples: "+1150,00 EUR". + private static final Pattern MONEY = Pattern.compile("([+-])(\\d+),(\\d{2}) ([A-Z]{3})"); + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("d.M.uuuu"); + + private static final String AMOUNT = "suma"; + private static final String COUNTER_ACCOUNT = "z účtu"; + private static final String COUNTER_NAME = "názov protiúčtu"; + private static final String REFERENCE = "referencia platiteľa"; + private static final String MESSAGE = "informácia pre príjemcu"; +} diff --git a/modules/bank-notifications/src/banks/sk/csob/test/cz/insuit/bank/notification/CsobSkTest.java b/modules/bank-notifications/src/banks/sk/csob/test/cz/insuit/bank/notification/CsobSkTest.java new file mode 100644 index 0000000..4082eb3 --- /dev/null +++ b/modules/bank-notifications/src/banks/sk/csob/test/cz/insuit/bank/notification/CsobSkTest.java @@ -0,0 +1,113 @@ +package cz.insuit.bank.notification; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import cz.insuit.bank.notification.BankNotificationException.Unparsed; +import cz.insuit.bank.notification.Transaction.Direction; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.Base64; +import java.util.Currency; +import java.util.Objects; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +/** + * The fixture is synthetic: windows-1250 in base64 like remp2020's raw ČSOB SK e-mail (MIT), its body that + * e-mail's with a valid fake counter-party IBAN (the sample's is anonymised and fails its check digits), Wayne + * Enterprises and Alfred. The attacks follow tomaj/bank-mails-parser's {@code utok-*} fixtures (MIT). + */ +class CsobSkTest { + + private static final Charset WINDOWS_1250 = Charset.forName("windows-1250"); + + @Test + void readsASepaCredit() throws IOException { + assertEquals( + new Transaction( + "csob.sk", + Direction.INCOMING, + Transaction.Type.TRANSFER, + BankAccount.parseIban("SK13 7500 0000 0040 1942 5381"), + BankAccount.parseIban("SK75 0900 0000 0012 3456 7899"), + "PENNYWORTH ALFRED", + new Money(new BigDecimal("1150.00"), Currency.getInstance("EUR")), + new PaymentSymbols("212049000", "0308", "964"), + "Batmobil 2019", + LocalDate.of(2019, 9, 6), + null, + null, + "<0000@csob.sk>"), + BankNotifications.parseUnverified(fixture())); + } + + @Test + void readsAReferenceThePayerDidNotGive() throws IOException { + Transaction read = parse(body -> body.replace("/VS212049000/SS964/KS0308", "NOTPROVIDED")); + + assertEquals(PaymentSymbols.NONE, read.symbols()); + } + + @Test + void refusesAnOutgoingPaymentNotSeenYet() { + assertThrows(Unparsed.class, () -> parse(body -> body.replace("+1150,00 EUR", "-1150,00 EUR"))); + } + + @Test + void refusesATemplateWithoutThePayersReferenceLine() { + assertThrows( + Unparsed.class, + () -> parse(body -> body.replace("referencia platiteľa: /VS212049000/SS964/KS0308\r\n", ""))); + } + + @Test + void refusesAReferenceSmuggledInThroughTheCounterPartysName() { + // The template prints the reference every time, so a forged one in the name makes a second. + assertThrows( + Unparsed.class, + () -> parse(body -> + body.replace("PENNYWORTH ALFRED\r\n", "EVIL\r\nreferencia platiteľa: /VS42/SS/KS\r\nX\r\n"))); + } + + @Test + void refusesATransactionForgedInsideTheMessage() { + // tomaj's utok-fantom-sprava: a second booking sentence and amount behind the message. + assertThrows( + Unparsed.class, + () -> parse(body -> body.replace( + "Batmobil 2019\r\n", + "Batmobil 2019\r\ndovoľujeme si Vám oznámiť, že dňa 1.1.2026 bola na účte SK13 7500 0000 0040 1942" + + " 5381 X zaúčtovaná suma SEPA platobného príkazu:\r\nsuma: +99999,00 EUR\r\n"))); + } + + @Test + void refusesAnAccountSmuggledInAfterTheMessage() { + // tomaj's utok-ucet-v-sprave: the counter-account moved behind the message. + assertThrows( + Unparsed.class, + () -> parse(body -> body.replace("z účtu: SK75 0900 0000 0012 3456 7899\r\n", "") + .replace("Batmobil 2019\r\n", "Batmobil 2019\r\nz účtu: SK49 1100 0000 0098 7654 3211\r\n"))); + } + + /** The fixture with its body decoded, changed, and encoded back, as the bank's windows-1250 in base64. */ + private static Transaction parse(UnaryOperator change) throws IOException { + String raw = new String(fixture(), StandardCharsets.US_ASCII); + int split = raw.indexOf("\r\n\r\n") + 4; + String body = new String(Base64.getMimeDecoder().decode(raw.substring(split)), WINDOWS_1250); + String encoded = + Base64.getMimeEncoder().encodeToString(change.apply(body).getBytes(WINDOWS_1250)); + return BankNotifications.parseUnverified( + (raw.substring(0, split) + encoded + "\r\n").getBytes(StandardCharsets.US_ASCII)); + } + + private static byte[] fixture() throws IOException { + try (InputStream in = CsobSkTest.class.getResourceAsStream("/csob-sk/incoming-synthetic.eml")) { + return Objects.requireNonNull(in).readAllBytes(); + } + } +} diff --git a/modules/bank-notifications/src/main/java/cz/insuit/bank/notification/BankNotifications.java b/modules/bank-notifications/src/main/java/cz/insuit/bank/notification/BankNotifications.java index 00d3879..d089361 100644 --- a/modules/bank-notifications/src/main/java/cz/insuit/bank/notification/BankNotifications.java +++ b/modules/bank-notifications/src/main/java/cz/insuit/bank/notification/BankNotifications.java @@ -45,7 +45,8 @@ public final class BankNotifications { new Creditas(), new Raiffeisenbank(), new Unicredit(), - new Csob()); + new Csob(), + new CsobSk()); /** * The one to use. Looks each bank's DKIM key up in DNS, accepts it only when the answer is DNSSEC-secure, validated