Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .docs/banks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
36 changes: 36 additions & 0 deletions .docs/banks/sk/csob/README.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Date: Fri, 06 Sep 2019 14:09:38 +0200
From: =?windows-1250?q?=C8SOB?= <AdminTBS@csob.sk>
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==
Original file line number Diff line number Diff line change
@@ -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.
*
* <p><b>Read from public samples, not from a real signed e-mail</b>: the template follows
* <a href="https://github.com/tomaj/bank-mails-parser">tomaj/bank-mails-parser</a>'s fixtures and
* <a href="https://github.com/remp2020/crm-payments-module">remp2020/crm-payments-module</a>'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.
*
* <p>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<String> KEY_FINGERPRINTS = Set.of();

@Override
public String domain() {
return DOMAIN;
}

@Override
public Set<String> 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<String, String> 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";
}
Original file line number Diff line number Diff line change
@@ -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 <b>synthetic</b>: 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<String> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading