From 5dbb78287bb3ff6794d8e4bbbf500aff1773942d Mon Sep 17 00:00:00 2001 From: Uwe Maurer Date: Mon, 24 Nov 2025 19:06:15 +0100 Subject: [PATCH 01/14] add missing EbicsUploadParams file --- .gitignore | 2 +- .../java/org/kopi/ebics/client/EbicsUploadParams.java | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/kopi/ebics/client/EbicsUploadParams.java diff --git a/.gitignore b/.gitignore index 0898a33b..a799df63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /target/ .idea *.iml -client/ +/client/ diff --git a/src/main/java/org/kopi/ebics/client/EbicsUploadParams.java b/src/main/java/org/kopi/ebics/client/EbicsUploadParams.java new file mode 100644 index 00000000..327dfdab --- /dev/null +++ b/src/main/java/org/kopi/ebics/client/EbicsUploadParams.java @@ -0,0 +1,8 @@ +package org.kopi.ebics.client; + +public record EbicsUploadParams(String orderId, OrderParams orderParams) { + + public record OrderParams(String serviceName, String scope, String option, String messageName, + String messageVersion, boolean signatureFlag) { + } +} From 81685a6ffd5446acc965c56d67438f6d83a9d5b2 Mon Sep 17 00:00:00 2001 From: Maic Stohr Date: Wed, 8 Apr 2026 17:51:08 +0200 Subject: [PATCH 02/14] feat(certificate): make key length and validity years configurable --- .../ebics/certificate/CertificateManager.java | 29 ++++++- .../certificate/CertificateManagerTest.java | 84 ++++++++++++++----- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/kopi/ebics/certificate/CertificateManager.java b/src/main/java/org/kopi/ebics/certificate/CertificateManager.java index ed887e16..e92d4656 100644 --- a/src/main/java/org/kopi/ebics/certificate/CertificateManager.java +++ b/src/main/java/org/kopi/ebics/certificate/CertificateManager.java @@ -42,6 +42,11 @@ */ public class CertificateManager { + private static final String KEY_LENGTH_PROPERTY = "ebics.key.length"; + private static final String CERTIFICATE_VALIDITY_YEARS_PROPERTY = "ebics.cert.validity.years"; + private static final int DEFAULT_KEY_LENGTH = X509Constants.EBICS_KEY_SIZE; + private static final int DEFAULT_CERTIFICATE_VALIDITY_YEARS = X509Constants.DEFAULT_DURATION / 365; + public CertificateManager(EbicsUser user) { this.user = user; generator = new X509Generator(); @@ -54,7 +59,7 @@ public CertificateManager(EbicsUser user) { */ public void create() throws GeneralSecurityException, IOException { Calendar calendar = Calendar.getInstance(); - calendar.add(Calendar.DAY_OF_YEAR, X509Constants.DEFAULT_DURATION); + calendar.add(Calendar.YEAR, resolveCertificateValidityYears()); createA005Certificate(new Date(calendar.getTimeInMillis())); createX002Certificate(new Date(calendar.getTimeInMillis())); @@ -82,7 +87,7 @@ private void setUserCertificates() { * @throws IOException */ public void createA005Certificate(Date end) throws GeneralSecurityException, IOException { - KeyPair keypair = KeyUtil.makeKeyPair(X509Constants.EBICS_KEY_SIZE); + KeyPair keypair = KeyUtil.makeKeyPair(resolveKeyLength()); a005Certificate = generator.generateA005Certificate(keypair, user.getDN(), new Date(), end); a005PrivateKey = keypair.getPrivate(); } @@ -100,7 +105,7 @@ X509Certificate getA005Certificate() { public void createX002Certificate(Date end) throws GeneralSecurityException, IOException { KeyPair keypair; - keypair = KeyUtil.makeKeyPair(X509Constants.EBICS_KEY_SIZE); + keypair = KeyUtil.makeKeyPair(resolveKeyLength()); x002Certificate = generator.generateX002Certificate(keypair, user.getDN(), new Date(), @@ -117,7 +122,7 @@ public void createX002Certificate(Date end) throws GeneralSecurityException, IOE public void createE002Certificate(Date end) throws GeneralSecurityException, IOException { KeyPair keypair; - keypair = KeyUtil.makeKeyPair(X509Constants.EBICS_KEY_SIZE); + keypair = KeyUtil.makeKeyPair(resolveKeyLength()); e002Certificate = generator.generateE002Certificate(keypair, user.getDN(), new Date(), @@ -220,4 +225,20 @@ public void writePKCS12Certificate(char[] password, OutputStream fos) private PrivateKey a005PrivateKey; private PrivateKey x002PrivateKey; private PrivateKey e002PrivateKey; + + private int resolveKeyLength() { + Integer configuredKeyLength = Integer.getInteger(KEY_LENGTH_PROPERTY); + if (configuredKeyLength == null || configuredKeyLength <= 0) { + return DEFAULT_KEY_LENGTH; + } + return configuredKeyLength; + } + + private int resolveCertificateValidityYears() { + Integer configuredYears = Integer.getInteger(CERTIFICATE_VALIDITY_YEARS_PROPERTY); + if (configuredYears == null || configuredYears <= 0) { + return DEFAULT_CERTIFICATE_VALIDITY_YEARS; + } + return configuredYears; + } } diff --git a/src/test/java/org/kopi/ebics/certificate/CertificateManagerTest.java b/src/test/java/org/kopi/ebics/certificate/CertificateManagerTest.java index b51d7c85..d2be9c1a 100644 --- a/src/test/java/org/kopi/ebics/certificate/CertificateManagerTest.java +++ b/src/test/java/org/kopi/ebics/certificate/CertificateManagerTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.security.GeneralSecurityException; @@ -9,6 +10,7 @@ import java.security.Security; import java.security.cert.X509Certificate; import java.security.interfaces.RSAPublicKey; +import java.time.temporal.ChronoUnit; import java.util.Calendar; import java.util.Date; @@ -30,7 +32,69 @@ class CertificateManagerTest { @Test void createA005Certificate() throws GeneralSecurityException, IOException { - var user = new EbicsUser() { + var user = testUser(); + var manager = new CertificateManager(user); + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.DAY_OF_YEAR, X509Constants.DEFAULT_DURATION); + + manager.createA005Certificate(new Date(calendar.getTimeInMillis())); + + var cert = manager.getA005Certificate(); + + assertNotNull(cert); + + //System.out.println(cert); + + assertEquals(3, cert.getVersion(), "Certificate version must be 3 (V3)."); + String expectedDN = "CN=test-dn"; + assertEquals(expectedDN, cert.getIssuerX500Principal().getName(X500Principal.RFC2253)); + assertEquals(expectedDN, cert.getSubjectX500Principal().getName(X500Principal.RFC2253)); + assertEquals("SHA256WITHRSA", cert.getSigAlgName()); + } + + @Test + void createUsesConfiguredKeyLength() throws Exception { + String previousKeyLength = System.getProperty("ebics.key.length"); + System.setProperty("ebics.key.length", "3072"); + try { + var manager = new CertificateManager(testUser()); + manager.create(); + var cert = manager.getA005Certificate(); + assertNotNull(cert); + assertEquals(3072, ((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength()); + } finally { + if (previousKeyLength == null) { + System.clearProperty("ebics.key.length"); + } else { + System.setProperty("ebics.key.length", previousKeyLength); + } + } + } + + @Test + void createUsesConfiguredCertificateValidityYears() throws Exception { + String previousValidityYears = System.getProperty("ebics.cert.validity.years"); + System.setProperty("ebics.cert.validity.years", "2"); + try { + var manager = new CertificateManager(testUser()); + manager.create(); + var cert = manager.getA005Certificate(); + assertNotNull(cert); + long validDays = ChronoUnit.DAYS.between( + cert.getNotBefore().toInstant(), + cert.getNotAfter().toInstant()); + assertTrue(validDays >= 730 && validDays <= 732); + } finally { + if (previousValidityYears == null) { + System.clearProperty("ebics.cert.validity.years"); + } else { + System.setProperty("ebics.cert.validity.years", previousValidityYears); + } + } + } + + private EbicsUser testUser() { + return new EbicsUser() { @Override public RSAPublicKey getA005PublicKey() { return null; @@ -136,24 +200,6 @@ public byte[] decrypt(byte[] encryptedKey, byte[] transactionKey) throws GeneralSecurityException, IOException, EbicsException { return new byte[0]; } - }; - var manager = new CertificateManager(user); - Calendar calendar = Calendar.getInstance(); - calendar.add(Calendar.DAY_OF_YEAR, X509Constants.DEFAULT_DURATION); - - manager.createA005Certificate(new Date(calendar.getTimeInMillis())); - - var cert = manager.getA005Certificate(); - - assertNotNull(cert); - - //System.out.println(cert); - - assertEquals(3, cert.getVersion(), "Certificate version must be 3 (V3)."); - String expectedDN = "CN=test-dn"; - assertEquals(expectedDN, cert.getIssuerX500Principal().getName(X500Principal.RFC2253)); - assertEquals(expectedDN, cert.getSubjectX500Principal().getName(X500Principal.RFC2253)); - assertEquals("SHA256WITHRSA", cert.getSigAlgName()); } } From 946bd4c37bfb78d251a202c605d867179ac25dde Mon Sep 17 00:00:00 2001 From: Maic Stohr Date: Wed, 8 Apr 2026 17:54:00 +0200 Subject: [PATCH 03/14] feat(cli): add parameterized launcher for runtime-configured environments --- README.md | 17 +- .../ParameterizedEbicsClientLauncher.java | 407 ++++++++++++++++++ .../ParameterizedEbicsClientLauncherTest.java | 47 ++ 3 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java create mode 100644 src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java diff --git a/README.md b/README.md index feadd9e1..2665a523 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,22 @@ How to get started: https://github.com/ebics-java/ebics-java-client/wiki/EBICS-Client-HowTo +Parameterized launcher (without `ebics.txt`): + +``` +export EBICS_PASSWORD='changeit' +export EBICS_USER_ID='USER123' +export EBICS_PARTNER_ID='PARTNER123' +export EBICS_HOST_ID='HOST123' +export EBICS_BANK_URL='https://bank.example/ebics' + +mvn exec:java \ + -Dexec.mainClass=org.kopi.ebics.client.ParameterizedEbicsClientLauncher \ + -Dexec.args="--create --ini --hia --hpb" +``` + +This mode is useful for containerized or ephemeral environments where `ebics.txt` should not be persisted. + You can build it directly from the source with maven or use the releases from [JitPack](https://jitpack.io/#ebics-java/ebics-java-client/). Gradle: @@ -47,4 +63,3 @@ Maven ``` - diff --git a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java new file mode 100644 index 00000000..a24872ee --- /dev/null +++ b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java @@ -0,0 +1,407 @@ +/* + * Copyright (c) 1990-2012 kopiLeft Development SARL, Bizerte, Tunisia + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1 as published by the Free Software Foundation. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.kopi.ebics.client; + +import java.io.File; +import java.net.URL; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; +import org.kopi.ebics.interfaces.EbicsBank; +import org.kopi.ebics.interfaces.EbicsPartner; +import org.kopi.ebics.interfaces.PasswordCallback; +import org.kopi.ebics.session.DefaultConfiguration; +import org.kopi.ebics.session.OrderType; +import org.kopi.ebics.session.Product; + +/** + * Parameter-based launcher that avoids relying on a persisted ebics.txt file in the workspace. + * It receives runtime parameters from environment variables. + */ +public final class ParameterizedEbicsClientLauncher { + private static final Set RESERVED_FLAGS = Set.of( + "--create", + "--ini", + "--hia", + "--hpb", + "--help" + ); + + private ParameterizedEbicsClientLauncher() { + } + + public static void main(String[] args) throws Exception { + ParsedArguments parsedArguments = ParsedArguments.parse(args); + if (parsedArguments.hasFlag("--help")) { + printUsage(); + return; + } + + String passphrase = requiredEnv("EBICS_PASSWORD"); + String userId = requiredEnv("EBICS_USER_ID"); + String partnerId = requiredEnv("EBICS_PARTNER_ID"); + String hostId = requiredEnv("EBICS_HOST_ID"); + String bankUrl = requiredEnv("EBICS_BANK_URL"); + String languageCode = env("EBICS_LANGUAGE_CODE", "de"); + String countryCode = env("EBICS_COUNTRY_CODE", "DE").toUpperCase(Locale.ROOT); + + propagateOptionalSystemProperty( + "ebics.key.length", + normalize(System.getenv("EBICS_KEY_LENGTH")) + ); + propagateOptionalSystemProperty( + "ebics.cert.validity.years", + normalize(System.getenv("EBICS_CERT_VALIDITY_YEARS")) + ); + + Properties properties = buildConfigurationProperties(languageCode, countryCode); + File rootDirectory = rootDirectory(); + DefaultConfiguration configuration = createConfiguration( + rootDirectory, + properties, + languageCode, + countryCode + ); + EbicsClient client = new EbicsClient(configuration, null); + Product product = new Product( + env("EBICS_PRODUCT_NAME", "EBICS Java Client"), + languageCode, + null + ); + PasswordCallback passwordCallback = () -> passphrase.toCharArray(); + + User user; + if (parsedArguments.hasFlag("--create")) { + user = client.createUser( + new URL(bankUrl), + env("EBICS_BANK_NAME", hostId), + hostId, + partnerId, + userId, + env("EBICS_USER_NAME", userId), + env("EBICS_USER_EMAIL", userId + "@example.invalid"), + env("EBICS_USER_COUNTRY", countryCode), + env("EBICS_USER_ORGANIZATION", "EBICS"), + resolveUseCertificate(), + true, + passwordCallback + ); + } else { + user = client.loadUser(hostId, partnerId, userId, passwordCallback); + ensureLoadedUserMatchesConfiguredEndpoint(user, bankUrl, hostId); + } + + if (parsedArguments.hasFlag("--ini")) { + client.sendINIRequest(user, product); + } + if (parsedArguments.hasFlag("--hia")) { + client.sendHIARequest(user, product); + } + if (parsedArguments.hasFlag("--hpb")) { + client.sendHPBRequest(user, product); + } + + String orderFlag = parsedArguments.firstOrderFlag(); + if (orderFlag != null) { + OrderType orderType = OrderType.valueOf(orderFlag.substring(2).toUpperCase(Locale.ROOT)); + if (parsedArguments.inputPath() != null) { + client.sendFile( + new File(parsedArguments.inputPath()), + user, + product, + orderType, + defaultUploadParams(user, orderType) + ); + } else if (parsedArguments.outputPath() != null) { + if (parsedArguments.startDate() != null || parsedArguments.endDate() != null) { + System.err.println( + "Date range arguments are ignored in parameterized mode for this order type." + ); + } + client.fetchFile( + new File(parsedArguments.outputPath()), + user, + product, + orderType, + Boolean.parseBoolean(env("EBICS_TEST_MODE", "false")) + ); + } + } + + client.quit(); + } + + private static void printUsage() { + String usage = "Usage: ParameterizedEbicsClientLauncher [--create] [--ini] [--hia] [--hpb]" + + " [--] [-i inputFile] [-o outputFile]\n" + + "Required environment variables: EBICS_PASSWORD, EBICS_USER_ID, EBICS_PARTNER_ID," + + " EBICS_HOST_ID, EBICS_BANK_URL"; + System.out.println(usage); + } + + private static EbicsUploadParams defaultUploadParams(User user, OrderType orderType) { + if (orderType == OrderType.XE2) { + var orderParams = new EbicsUploadParams.OrderParams( + "MCT", + "CH", + null, + "pain.001", + "03", + true + ); + return new EbicsUploadParams(null, orderParams); + } + return new EbicsUploadParams(user.getPartner().nextOrderId(), null); + } + + private static File rootDirectory() { + String explicit = normalize(System.getenv("EBICS_ROOT_DIR")); + if (explicit != null) { + return new File(explicit); + } + String userHome = System.getProperty("user.home"); + if (userHome == null || userHome.isBlank()) { + throw new IllegalStateException("Missing user.home for EBICS workspace resolution."); + } + return new File(new File(userHome), "ebics/client"); + } + + private static DefaultConfiguration createConfiguration( + File rootDirectory, + Properties properties, + String languageCode, + String countryCode + ) { + Locale locale = new Locale( + languageCode.toLowerCase(Locale.ROOT), + countryCode.toUpperCase(Locale.ROOT) + ); + return new DefaultConfiguration(rootDirectory, properties) { + @Override + public Locale getLocale() { + return locale; + } + }; + } + + private static Properties buildConfigurationProperties( + String languageCode, + String countryCode + ) { + Properties properties = new Properties(); + properties.setProperty("conf.file.name", "ebics.properties"); + properties.setProperty("keystore.dir.name", "keystore"); + properties.setProperty("traces.dir.name", "traces"); + properties.setProperty("serialization.dir.name", "serialized"); + properties.setProperty("ssltruststore.dir.name", "ssl"); + properties.setProperty("sslkeystore.dir.name", "ssl"); + properties.setProperty("sslbankcert.dir.name", "ssl"); + properties.setProperty("users.dir.name", "users"); + properties.setProperty("letters.dir.name", "letters"); + properties.setProperty("signature.version", env("EBICS_SIGNATURE_VERSION", "A005")); + properties.setProperty("authentication.version", env("EBICS_AUTHENTICATION_VERSION", "X002")); + properties.setProperty("encryption.version", env("EBICS_ENCRYPTION_VERSION", "E002")); + properties.setProperty("ebics.version", env("EBICS_VERSION", "H003")); + properties.setProperty("languageCode", languageCode); + properties.setProperty("countryCode", countryCode); + return properties; + } + + private static boolean resolveUseCertificate() { + String explicit = normalize(System.getenv("EBICS_USE_CERTIFICATE")); + if (explicit != null) { + return "true".equalsIgnoreCase(explicit); + } + String signatureVersion = env("EBICS_SIGNATURE_VERSION", "A005"); + return "A006".equalsIgnoreCase(signatureVersion); + } + + private static void ensureLoadedUserMatchesConfiguredEndpoint( + User user, + String configuredBankUrl, + String configuredHostId + ) { + if (user == null) { + return; + } + + EbicsPartner partner = user.getPartner(); + EbicsBank bank = partner == null ? null : partner.getBank(); + if (bank == null) { + return; + } + + String expectedUrl = normalize(configuredBankUrl); + String loadedUrl = bank.getURL() == null ? null : normalize(bank.getURL().toString()); + if (expectedUrl != null && !expectedUrl.equals(loadedUrl)) { + throw new IllegalStateException( + "Loaded user endpoint does not match configured EBICS_BANK_URL. " + + "Run with --create or clean serialized state." + ); + } + + String expectedHostId = normalize(configuredHostId); + String loadedHostId = normalize(bank.getHostId()); + if ( + expectedHostId != null && + loadedHostId != null && + !expectedHostId.equals(loadedHostId) + ) { + throw new IllegalStateException( + "Loaded user host id does not match configured EBICS_HOST_ID. " + + "Run with --create or clean serialized state." + ); + } + } + + private static void propagateOptionalSystemProperty(String key, String value) { + if (value != null) { + System.setProperty(key, value); + } + } + + private static String requiredEnv(String key) { + String value = normalize(System.getenv(key)); + if (value == null) { + throw new IllegalArgumentException("Missing required environment variable: " + key); + } + return value; + } + + private static String env(String key, String fallback) { + String value = normalize(System.getenv(key)); + return value == null ? fallback : value; + } + + static String normalize(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + static final class ParsedArguments { + private final Set flags = new LinkedHashSet<>(); + private final String inputPath; + private final String outputPath; + private final String startDate; + private final String endDate; + + private ParsedArguments( + Set flags, + String inputPath, + String outputPath, + String startDate, + String endDate + ) { + this.flags.addAll(flags); + this.inputPath = inputPath; + this.outputPath = outputPath; + this.startDate = startDate; + this.endDate = endDate; + } + + static ParsedArguments parse(String[] args) { + Set flags = new LinkedHashSet<>(); + String inputPath = null; + String outputPath = null; + String startDate = null; + String endDate = null; + if (args != null) { + for (int index = 0; index < args.length; index++) { + String arg = args[index]; + if (arg == null || arg.isBlank()) { + continue; + } + if ("-i".equals(arg) || "--input".equals(arg)) { + inputPath = requireValue(args, ++index, arg); + continue; + } + if ("-o".equals(arg) || "--output".equals(arg)) { + outputPath = requireValue(args, ++index, arg); + continue; + } + if ("-s".equals(arg) || "--start".equals(arg)) { + startDate = requireValue(args, ++index, arg); + continue; + } + if ("-e".equals(arg) || "--end".equals(arg)) { + endDate = requireValue(args, ++index, arg); + continue; + } + if (arg.startsWith("--")) { + flags.add(arg.toLowerCase(Locale.ROOT)); + } + } + } + return new ParsedArguments(flags, inputPath, outputPath, startDate, endDate); + } + + private static String requireValue(String[] args, int index, String option) { + if (args == null || index >= args.length) { + throw new IllegalArgumentException("Missing value for option " + option); + } + String value = normalize(args[index]); + if (value == null) { + throw new IllegalArgumentException("Missing value for option " + option); + } + return value; + } + + boolean hasFlag(String flag) { + return flags.contains(flag.toLowerCase(Locale.ROOT)); + } + + String firstOrderFlag() { + for (String flag : flags) { + if (RESERVED_FLAGS.contains(flag)) { + continue; + } + String candidate = flag.startsWith("--") + ? flag.substring(2).toUpperCase(Locale.ROOT) + : flag.toUpperCase(Locale.ROOT); + try { + OrderType.valueOf(candidate); + return flag; + } catch (IllegalArgumentException ignored) { + // ignore unknown flags that are not EBICS order types + } + } + return null; + } + + String inputPath() { + return inputPath; + } + + String outputPath() { + return outputPath; + } + + String startDate() { + return startDate; + } + + String endDate() { + return endDate; + } + } +} diff --git a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java new file mode 100644 index 00000000..5efaee4a --- /dev/null +++ b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java @@ -0,0 +1,47 @@ +package org.kopi.ebics.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ParameterizedEbicsClientLauncherTest { + @Test + void parsesFlagsAndInputOutputOptions() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ "--create", "--ini", "--sta", "-o", "sta.xml", "-s", "2026-01-01" } + ); + + assertTrue(parsed.hasFlag("--create")); + assertTrue(parsed.hasFlag("--ini")); + assertEquals("--sta", parsed.firstOrderFlag()); + assertEquals("sta.xml", parsed.outputPath()); + assertEquals("2026-01-01", parsed.startDate()); + } + + @Test + void ignoresReservedFlagsWhenResolvingOrder() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ "--create", "--ini", "--hpb" } + ); + + assertNull(parsed.firstOrderFlag()); + } + + @Test + void rejectsMissingOptionValue() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.ParsedArguments.parse(new String[]{ "-o" }) + ); + assertTrue(exception.getMessage().contains("Missing value for option -o")); + } + + @Test + void normalizeHandlesBlankValues() { + assertNull(ParameterizedEbicsClientLauncher.normalize(" ")); + assertEquals("value", ParameterizedEbicsClientLauncher.normalize(" value ")); + } +} From 5bebe9df6d5b390a275b6cd5c3033e1d4ae9feec Mon Sep 17 00:00:00 2001 From: Maic Stohr Date: Wed, 8 Apr 2026 22:14:24 +0200 Subject: [PATCH 04/14] ci: skip dependency graph submission for fork PRs --- .github/workflows/maven.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 6031fcd7..fa7dfd95 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -8,7 +8,8 @@ on: jobs: build: - + permissions: + contents: write runs-on: ubuntu-latest steps: @@ -24,4 +25,5 @@ jobs: # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive - name: Update dependency graph + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 From abd0b8d26412698d6ac42c62d5f2ecce4d4eeaf3 Mon Sep 17 00:00:00 2001 From: Maic Stohr Date: Wed, 8 Apr 2026 22:14:24 +0200 Subject: [PATCH 05/14] ci: skip dependency graph submission for fork PRs --- .github/workflows/maven.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 6031fcd7..fa7dfd95 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -8,7 +8,8 @@ on: jobs: build: - + permissions: + contents: write runs-on: ubuntu-latest steps: @@ -24,4 +25,5 @@ jobs: # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive - name: Update dependency graph + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 From 985f04929956c55185722e30706c314864af0697 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:14:17 +0000 Subject: [PATCH 06/14] Bump org.bouncycastle:bcprov-jdk18on from 1.82 to 1.84 Bumps [org.bouncycastle:bcprov-jdk18on](https://github.com/bcgit/bc-java) from 1.82 to 1.84. - [Changelog](https://github.com/bcgit/bc-java/blob/main/docs/releasenotes.html) - [Commits](https://github.com/bcgit/bc-java/commits) --- updated-dependencies: - dependency-name: org.bouncycastle:bcprov-jdk18on dependency-version: '1.84' dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 2b9d2e3d..d067ac2b 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ org.bouncycastle bcprov-jdk18on - 1.82 + 1.84 org.slf4j From 2818fa9decabee97bf7d86cd84cf949d9aaacf96 Mon Sep 17 00:00:00 2001 From: Uwe Maurer Date: Fri, 22 May 2026 15:53:16 +0200 Subject: [PATCH 07/14] fix(letter): hash DER certificate in INI/HIA letters for EBICS 3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The letters printed the SHA-256 of the public key by default, but the INI/HIA requests always transmit the X.509 certificate. Under H005 the letter must carry the SHA-256 of the DER-encoded certificate (spec 4.4.1.2.3), which the bank verifies against the request — the public-key hash failed bank-side verification fixes https://github.com/ebics-java/ebics-java-client/discussions/49 --- src/main/java/org/kopi/ebics/client/Bank.java | 21 +----- .../org/kopi/ebics/client/EbicsClient.java | 22 +++--- .../ParameterizedEbicsClientLauncher.java | 10 --- .../org/kopi/ebics/interfaces/EbicsBank.java | 10 --- .../org/kopi/ebics/letter/A005Letter.java | 36 ++++------ .../kopi/ebics/letter/AbstractInitLetter.java | 43 ++---------- .../org/kopi/ebics/letter/E002Letter.java | 36 ++++------ .../org/kopi/ebics/letter/X002Letter.java | 36 ++++------ .../kopi/ebics/letter/InitLetterHashTest.java | 69 +++++++++++++++++++ 9 files changed, 122 insertions(+), 161 deletions(-) create mode 100644 src/test/java/org/kopi/ebics/letter/InitLetterHashTest.java diff --git a/src/main/java/org/kopi/ebics/client/Bank.java b/src/main/java/org/kopi/ebics/client/Bank.java index 0a3e84dd..ee8afb36 100644 --- a/src/main/java/org/kopi/ebics/client/Bank.java +++ b/src/main/java/org/kopi/ebics/client/Bank.java @@ -42,13 +42,11 @@ public class Bank implements EbicsBank, Savable { * @param url the bank URL * @param name the bank name * @param hostId the bank host ID - * @param useCertificate does the bank use certificates for exchange ? */ - public Bank(URL url, String name, String hostId, boolean useCertificate) { + public Bank(URL url, String name, String hostId) { this.url = url; this.name = name; this.hostId = hostId; - this.useCertificate = useCertificate; needSave = true; } @@ -127,17 +125,6 @@ public String getName() { return name; } - @Override - public boolean useCertificate() { - return useCertificate; - } - - @Override - public void setUseCertificate(boolean useCertificate) { - this.useCertificate = useCertificate; - needSave = true; - } - @Override public void setBankKeys(RSAPublicKey e002Key, RSAPublicKey x002Key) { this.e002Key = e002Key; @@ -172,12 +159,6 @@ public String getSaveName() { * @serial */ private final String hostId; - - /** - * Does the bank use certificates for signing/crypting ? - * @serial - */ - private boolean useCertificate; /** * The bank name diff --git a/src/main/java/org/kopi/ebics/client/EbicsClient.java b/src/main/java/org/kopi/ebics/client/EbicsClient.java index d743d31e..8479a7f5 100644 --- a/src/main/java/org/kopi/ebics/client/EbicsClient.java +++ b/src/main/java/org/kopi/ebics/client/EbicsClient.java @@ -135,12 +135,10 @@ public void createUserDirectories(EbicsUser user) { * the bank name * @param hostId * the bank host ID - * @param useCertificate - * does the bank use certificates ? * @return the created ebics bank */ - private Bank createBank(URL url, String name, String hostId, boolean useCertificate) { - Bank bank = new Bank(url, name, hostId, useCertificate); + private Bank createBank(URL url, String name, String hostId) { + Bank bank = new Bank(url, name, hostId); banks.put(hostId, bank); return bank; } @@ -180,8 +178,6 @@ private Partner createPartner(EbicsBank bank, String partnerId) { * the user country * @param organization * the user organization or company - * @param useCertificates - * does the bank use certificates ? * @param saveCertificates * save generated certificates? * @param passwordCallback @@ -192,11 +188,11 @@ private Partner createPartner(EbicsBank bank, String partnerId) { */ public User createUser(URL url, String bankName, String hostId, String partnerId, String userId, String name, String email, String country, String organization, - boolean useCertificates, boolean saveCertificates, PasswordCallback passwordCallback) + boolean saveCertificates, PasswordCallback passwordCallback) throws Exception { log.info(messages.getString("user.create.info", userId)); - Bank bank = createBank(url, bankName, hostId, useCertificates); + Bank bank = createBank(url, bankName, hostId); Partner partner = createPartner(bank, partnerId); try { User user = new User(partner, userId, name, email, country, organization, @@ -208,7 +204,7 @@ public User createUser(URL url, String bankName, String hostId, String partnerId configuration.getSerializationManager().serialize(bank); configuration.getSerializationManager().serialize(partner); configuration.getSerializationManager().serialize(user); - createLetters(user, useCertificates); + createLetters(user); users.put(userId, user); partners.put(partner.getPartnerId(), partner); banks.put(bank.getHostId(), bank); @@ -221,9 +217,8 @@ public User createUser(URL url, String bankName, String hostId, String partnerId } } - private void createLetters(EbicsUser user, boolean useCertificates) + private void createLetters(EbicsUser user) throws GeneralSecurityException, IOException, EbicsException { - user.getPartner().getBank().setUseCertificate(useCertificates); LetterManager letterManager = configuration.getLetterManager(); List letters = List.of(letterManager.createA005Letter(user), letterManager.createE002Letter(user), letterManager.createX002Letter(user)); @@ -512,10 +507,9 @@ private User createUser(ConfigProperties properties, PasswordCallback pwdHandler String userEmail = properties.get("user.email"); String userCountry = properties.get("user.country"); String userOrg = properties.get("user.org"); - boolean useCertificates = false; boolean saveCertificates = true; return createUser(new URL(bankUrl), bankName, hostId, partnerId, userId, userName, userEmail, - userCountry, userOrg, useCertificates, saveCertificates, pwdHandler); + userCountry, userOrg, saveCertificates, pwdHandler); } private static CommandLine parseArguments(Options options, String[] args) @@ -640,7 +634,7 @@ public static void main(String[] args) throws Exception { } if (cmd.hasOption("letters")) { - client.createLetters(client.defaultUser, false); + client.createLetters(client.defaultUser); } if (hasOption(cmd, OrderType.INI)) { diff --git a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java index a24872ee..b220222f 100644 --- a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java +++ b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java @@ -99,7 +99,6 @@ public static void main(String[] args) throws Exception { env("EBICS_USER_EMAIL", userId + "@example.invalid"), env("EBICS_USER_COUNTRY", countryCode), env("EBICS_USER_ORGANIZATION", "EBICS"), - resolveUseCertificate(), true, passwordCallback ); @@ -224,15 +223,6 @@ private static Properties buildConfigurationProperties( return properties; } - private static boolean resolveUseCertificate() { - String explicit = normalize(System.getenv("EBICS_USE_CERTIFICATE")); - if (explicit != null) { - return "true".equalsIgnoreCase(explicit); - } - String signatureVersion = env("EBICS_SIGNATURE_VERSION", "A005"); - return "A006".equalsIgnoreCase(signatureVersion); - } - private static void ensureLoadedUserMatchesConfiguredEndpoint( User user, String configuredBankUrl, diff --git a/src/main/java/org/kopi/ebics/interfaces/EbicsBank.java b/src/main/java/org/kopi/ebics/interfaces/EbicsBank.java index d96aacd4..b35b2a38 100644 --- a/src/main/java/org/kopi/ebics/interfaces/EbicsBank.java +++ b/src/main/java/org/kopi/ebics/interfaces/EbicsBank.java @@ -35,16 +35,6 @@ public interface EbicsBank extends Serializable { */ URL getURL(); - /** - * - */ - boolean useCertificate(); - - /** - * - */ - void setUseCertificate(boolean useCertificate); - /** * Returns the encryption key digest you have obtained from the bank. * Ensure that nobody was able to modify the digest on its way from the bank to you. diff --git a/src/main/java/org/kopi/ebics/letter/A005Letter.java b/src/main/java/org/kopi/ebics/letter/A005Letter.java index bf0361d1..36049792 100644 --- a/src/main/java/org/kopi/ebics/letter/A005Letter.java +++ b/src/main/java/org/kopi/ebics/letter/A005Letter.java @@ -45,29 +45,19 @@ public A005Letter(Locale locale) { @Override public void create(EbicsUser user) throws GeneralSecurityException, IOException, EbicsException { - if (user.getPartner().getBank().useCertificate()) { - build(user.getPartner().getBank().getHostId(), - user.getPartner().getBank().getName(), - user.getUserId(), - user.getName(), - user.getPartner().getPartnerId(), - getString("INILetter.version"), - getString("INILetter.certificate"), - Base64.encodeBase64(user.getA005Certificate(), true), - getString("INILetter.digest"), - getHash(user.getA005Certificate())); - } else { - build(user.getPartner().getBank().getHostId(), - user.getPartner().getBank().getName(), - user.getUserId(), - user.getName(), - user.getPartner().getPartnerId(), - getString("INILetter.version"), - getString("INILetter.certificate"), - null, - getString("INILetter.digest"), - getHash(user.getA005PublicKey())); - } + // EBICS 3.0 (H005): the INI letter must carry the SHA-256 hash of the + // DER-encoded signature certificate (spec ch. 4.4.1.2.3), matching the + // X.509 certificate transmitted in the INI request. + build(user.getPartner().getBank().getHostId(), + user.getPartner().getBank().getName(), + user.getUserId(), + user.getName(), + user.getPartner().getPartnerId(), + getString("INILetter.version"), + getString("INILetter.certificate"), + Base64.encodeBase64(user.getA005Certificate(), true), + getString("INILetter.digest"), + getHash(user.getA005Certificate())); } @Override diff --git a/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java b/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java index 135566f3..4868049e 100644 --- a/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java +++ b/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java @@ -23,16 +23,13 @@ import java.io.OutputStream; import java.io.PrintWriter; import java.io.Writer; -import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.MessageDigest; -import java.security.interfaces.RSAPublicKey; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; import org.apache.commons.codec.binary.Hex; -import org.kopi.ebics.exception.EbicsException; import org.kopi.ebics.interfaces.InitLetter; import org.kopi.ebics.messages.Messages; @@ -100,8 +97,8 @@ protected String getString(String key) { } /** - * Returns the certificate hash - * @param certificate the certificate + * Returns the SHA-256 hash of the DER-encoded certificate. + * @param certificate the DER-encoded certificate * @return the certificate hash * @throws GeneralSecurityException */ @@ -111,36 +108,6 @@ protected byte[] getHash(byte[] certificate) throws GeneralSecurityException { return format(hash256).getBytes(); } - protected byte[] getHash(RSAPublicKey publicKey) throws EbicsException { - String modulus; - String exponent; - String hash; - byte[] digest; - - exponent = Hex.encodeHexString(publicKey.getPublicExponent().toByteArray()); - modulus = Hex.encodeHexString(removeFirstByte(publicKey.getModulus().toByteArray())); - hash = exponent + " " + modulus; - - if (hash.charAt(0) == '0') { - hash = hash.substring(1); - } - - try { - digest = MessageDigest.getInstance("SHA-256", "BC").digest(hash.getBytes( - StandardCharsets.US_ASCII)); - } catch (GeneralSecurityException e) { - throw new EbicsException(e.getMessage()); - } - - return format(new String(Hex.encodeHex(digest, false))).getBytes(); - } - - private static byte[] removeFirstByte(byte[] byteArray) { - byte[] b = new byte[byteArray.length - 1]; - System.arraycopy(byteArray, 1, b, 0, b.length); - return b; - } - /** * Formats a hash 256 input. * @param hash256 the hash input @@ -215,9 +182,9 @@ public void build(String certTitle, out = new ByteArrayOutputStream(); writer = new PrintWriter(out, true); buildTitle(); - buildHeader(); - if (certificate != null) { - buildCertificate(certTitle, certificate); + buildHeader(); + if (certificate != null) { + buildCertificate(certTitle, certificate); } buildHash(hashTitle, hash); buildFooter(); diff --git a/src/main/java/org/kopi/ebics/letter/E002Letter.java b/src/main/java/org/kopi/ebics/letter/E002Letter.java index 4eee4582..95b7873d 100644 --- a/src/main/java/org/kopi/ebics/letter/E002Letter.java +++ b/src/main/java/org/kopi/ebics/letter/E002Letter.java @@ -45,29 +45,19 @@ public E002Letter(Locale locale) { @Override public void create(EbicsUser user) throws GeneralSecurityException, IOException, EbicsException { - if (user.getPartner().getBank().useCertificate()) { - build(user.getPartner().getBank().getHostId(), - user.getPartner().getBank().getName(), - user.getUserId(), - user.getName(), - user.getPartner().getPartnerId(), - getString("HIALetter.e002.version"), - getString("HIALetter.e002.certificate"), - Base64.encodeBase64(user.getE002Certificate(), true), - getString("HIALetter.e002.digest"), - getHash(user.getE002Certificate())); - } else { - build(user.getPartner().getBank().getHostId(), - user.getPartner().getBank().getName(), - user.getUserId(), - user.getName(), - user.getPartner().getPartnerId(), - getString("HIALetter.e002.version"), - getString("HIALetter.e002.certificate"), - null, - getString("HIALetter.e002.digest"), - getHash(user.getE002PublicKey())); - } + // EBICS 3.0 (H005): the HIA letter must carry the SHA-256 hash of the + // DER-encoded encryption certificate (spec ch. 4.4.1.2.3), matching the + // X.509 certificate transmitted in the HIA request. + build(user.getPartner().getBank().getHostId(), + user.getPartner().getBank().getName(), + user.getUserId(), + user.getName(), + user.getPartner().getPartnerId(), + getString("HIALetter.e002.version"), + getString("HIALetter.e002.certificate"), + Base64.encodeBase64(user.getE002Certificate(), true), + getString("HIALetter.e002.digest"), + getHash(user.getE002Certificate())); } @Override diff --git a/src/main/java/org/kopi/ebics/letter/X002Letter.java b/src/main/java/org/kopi/ebics/letter/X002Letter.java index 2260f97c..d431ad63 100644 --- a/src/main/java/org/kopi/ebics/letter/X002Letter.java +++ b/src/main/java/org/kopi/ebics/letter/X002Letter.java @@ -45,29 +45,19 @@ public X002Letter(Locale locale) { @Override public void create(EbicsUser user) throws GeneralSecurityException, IOException, EbicsException { - if (user.getPartner().getBank().useCertificate()) { - build(user.getPartner().getBank().getHostId(), - user.getPartner().getBank().getName(), - user.getUserId(), - user.getName(), - user.getPartner().getPartnerId(), - getString("HIALetter.x002.version"), - getString("HIALetter.x002.certificate"), - Base64.encodeBase64(user.getX002Certificate(), true), - getString("HIALetter.x002.digest"), - getHash(user.getX002Certificate())); - } else { - build(user.getPartner().getBank().getHostId(), - user.getPartner().getBank().getName(), - user.getUserId(), - user.getName(), - user.getPartner().getPartnerId(), - getString("HIALetter.x002.version"), - getString("HIALetter.x002.certificate"), - null, - getString("HIALetter.x002.digest"), - getHash(user.getX002PublicKey())); - } + // EBICS 3.0 (H005): the HIA letter must carry the SHA-256 hash of the + // DER-encoded authentication certificate (spec ch. 4.4.1.2.3), matching + // the X.509 certificate transmitted in the HIA request. + build(user.getPartner().getBank().getHostId(), + user.getPartner().getBank().getName(), + user.getUserId(), + user.getName(), + user.getPartner().getPartnerId(), + getString("HIALetter.x002.version"), + getString("HIALetter.x002.certificate"), + Base64.encodeBase64(user.getX002Certificate(), true), + getString("HIALetter.x002.digest"), + getHash(user.getX002Certificate())); } @Override diff --git a/src/test/java/org/kopi/ebics/letter/InitLetterHashTest.java b/src/test/java/org/kopi/ebics/letter/InitLetterHashTest.java new file mode 100644 index 00000000..7e121b2f --- /dev/null +++ b/src/test/java/org/kopi/ebics/letter/InitLetterHashTest.java @@ -0,0 +1,69 @@ +package org.kopi.ebics.letter; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.net.URL; +import java.security.MessageDigest; +import java.security.Security; +import java.util.Locale; + +import org.apache.commons.codec.binary.Hex; +import org.apache.xml.security.Init; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.jupiter.api.Test; +import org.kopi.ebics.client.Bank; +import org.kopi.ebics.client.Partner; +import org.kopi.ebics.client.User; +import org.kopi.ebics.interfaces.InitLetter; + +/** + * Verifies that the INI and HIA letters carry the SHA-256 hash of the + * DER-encoded certificate, as required by EBICS 3.0 (H005), spec ch. 4.4.1.2.3. + * + *

Before this was fixed the letters printed the SHA-256 of the public key + * (the EBICS 2.5 form), which made the bank-side letter verification fail even + * though the INI/HIA request always transmits the X.509 certificate. + */ +class InitLetterHashTest { + static { + Init.init(); + Security.addProvider(new BouncyCastleProvider()); + } + + @Test + void lettersContainCertificateHash() throws Exception { + User user = testUser(); + + assertLetterContainsCertificateHash(new A005Letter(Locale.ENGLISH), user, + user.getA005Certificate()); + assertLetterContainsCertificateHash(new E002Letter(Locale.ENGLISH), user, + user.getE002Certificate()); + assertLetterContainsCertificateHash(new X002Letter(Locale.ENGLISH), user, + user.getX002Certificate()); + } + + private void assertLetterContainsCertificateHash(InitLetter letter, User user, byte[] der) + throws Exception { + letter.create(user); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + letter.writeTo(out); + // The hash is printed grouped into space-separated pairs across two + // lines; strip whitespace so we can match the contiguous hex digest. + String despaced = out.toString().replaceAll("\\s", "").toUpperCase(Locale.ROOT); + + String expected = Hex.encodeHexString( + MessageDigest.getInstance("SHA-256").digest(der)).toUpperCase(Locale.ROOT); + + assertTrue(despaced.contains(expected), + letter.getClass().getSimpleName() + + " must print the SHA-256 hash of the DER-encoded certificate"); + } + + private User testUser() throws Exception { + Bank bank = new Bank(new URL("https://bank.example/ebics"), "Test Bank", "HOSTID"); + Partner partner = new Partner(bank, "PARTNERID"); + return new User(partner, "USERID", "John Doe", "john@example.com", "DE", "ACME", + "changeit"::toCharArray); + } +} From 901faa3566ca3e8e78663fb9413e8d024f82913a Mon Sep 17 00:00:00 2001 From: Uwe Maurer Date: Wed, 27 May 2026 14:22:42 +0200 Subject: [PATCH 08/14] refactor: replace commons-codec with JDK Base64/HexFormat, drop gnu-crypto Java 17 ships java.util.Base64 (since 8) and java.util.HexFormat (since 17), so commons-codec is redundant. gnu-crypto was declared in pom.xml but never imported. --- pom.xml | 16 +- .../org/kopi/ebics/certificate/KeyUtil.java | 8 +- .../org/kopi/ebics/letter/A005Letter.java | 3 +- .../kopi/ebics/letter/AbstractInitLetter.java | 25 ++- .../org/kopi/ebics/letter/E002Letter.java | 3 +- .../org/kopi/ebics/letter/X002Letter.java | 3 +- .../xml/InitializationRequestElement.java | 7 +- .../UploadInitializationRequestElement.java | 4 +- .../org/kopi/ebics/CodecMigrationTest.java | 194 ++++++++++++++++++ .../kopi/ebics/letter/InitLetterHashTest.java | 6 +- 10 files changed, 234 insertions(+), 35 deletions(-) create mode 100644 src/test/java/org/kopi/ebics/CodecMigrationTest.java diff --git a/pom.xml b/pom.xml index d067ac2b..ae2639cb 100644 --- a/pom.xml +++ b/pom.xml @@ -13,11 +13,6 @@ xmlbeans 5.3.0 - - commons-codec - commons-codec - 1.15 - org.apache.httpcomponents httpclient @@ -39,11 +34,6 @@ 2.0.17 true - - org.gnu - gnu-crypto - 2.0.1 - org.apache.santuario xmlsec @@ -57,19 +47,19 @@ org.junit.jupiter junit-jupiter-api - 6.0.0 + 6.1.0 test org.junit.jupiter junit-jupiter-engine - 6.0.0 + 6.1.0 test org.mockito mockito-core - 3.2.4 + 5.23.0 test diff --git a/src/main/java/org/kopi/ebics/certificate/KeyUtil.java b/src/main/java/org/kopi/ebics/certificate/KeyUtil.java index 875beb1f..44f649d5 100644 --- a/src/main/java/org/kopi/ebics/certificate/KeyUtil.java +++ b/src/main/java/org/kopi/ebics/certificate/KeyUtil.java @@ -26,8 +26,8 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.interfaces.RSAPublicKey; +import java.util.HexFormat; -import org.apache.commons.codec.binary.Hex; import org.kopi.ebics.exception.EbicsException; import org.kopi.ebics.utils.Utils; @@ -80,8 +80,8 @@ public static byte[] getKeyDigest(RSAPublicKey publicKey) throws EbicsException String hash; byte[] digest; - exponent = Hex.encodeHexString(publicKey.getPublicExponent().toByteArray()); - modulus = Hex.encodeHexString(removeFirstByte(publicKey.getModulus().toByteArray())); + exponent = HexFormat.of().formatHex(publicKey.getPublicExponent().toByteArray()); + modulus = HexFormat.of().formatHex(removeFirstByte(publicKey.getModulus().toByteArray())); hash = exponent + " " + modulus; if (hash.charAt(0) == '0') { @@ -95,7 +95,7 @@ public static byte[] getKeyDigest(RSAPublicKey publicKey) throws EbicsException throw new EbicsException(e.getMessage()); } - return new String(Hex.encodeHex(digest, false)).getBytes(); + return HexFormat.of().withUpperCase().formatHex(digest).getBytes(StandardCharsets.US_ASCII); } /** diff --git a/src/main/java/org/kopi/ebics/letter/A005Letter.java b/src/main/java/org/kopi/ebics/letter/A005Letter.java index 36049792..5e878b3b 100644 --- a/src/main/java/org/kopi/ebics/letter/A005Letter.java +++ b/src/main/java/org/kopi/ebics/letter/A005Letter.java @@ -22,7 +22,6 @@ import java.security.GeneralSecurityException; import java.util.Locale; -import org.apache.commons.codec.binary.Base64; import org.kopi.ebics.exception.EbicsException; import org.kopi.ebics.interfaces.EbicsUser; @@ -55,7 +54,7 @@ public void create(EbicsUser user) throws GeneralSecurityException, IOException, user.getPartner().getPartnerId(), getString("INILetter.version"), getString("INILetter.certificate"), - Base64.encodeBase64(user.getA005Certificate(), true), + chunkedBase64(user.getA005Certificate()), getString("INILetter.digest"), getHash(user.getA005Certificate())); } diff --git a/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java b/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java index 4868049e..1905f26c 100644 --- a/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java +++ b/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java @@ -26,10 +26,11 @@ import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.text.SimpleDateFormat; +import java.util.Base64; import java.util.Date; +import java.util.HexFormat; import java.util.Locale; -import org.apache.commons.codec.binary.Hex; import org.kopi.ebics.interfaces.InitLetter; import org.kopi.ebics.messages.Messages; @@ -103,11 +104,29 @@ protected String getString(String key) { * @throws GeneralSecurityException */ protected byte[] getHash(byte[] certificate) throws GeneralSecurityException { - String hash256 = new String( - Hex.encodeHex(MessageDigest.getInstance("SHA-256").digest(certificate), false)); + String hash256 = HexFormat.of().withUpperCase().formatHex( + MessageDigest.getInstance("SHA-256").digest(certificate)); return format(hash256).getBytes(); } + /** + * Encodes {@code data} as MIME Base64 (76-character lines separated by CRLF) + * with a trailing CRLF, matching the historical commons-codec + * {@code Base64.encodeBase64(data, true)} byte-for-byte so PEM-style blocks + * in the letter keep the {@code -----END CERTIFICATE-----} marker on its own line. + */ + protected static byte[] chunkedBase64(byte[] data) { + byte[] encoded = Base64.getMimeEncoder().encode(data); + if (encoded.length == 0) { + return encoded; + } + byte[] result = new byte[encoded.length + 2]; + System.arraycopy(encoded, 0, result, 0, encoded.length); + result[encoded.length] = '\r'; + result[encoded.length + 1] = '\n'; + return result; + } + /** * Formats a hash 256 input. * @param hash256 the hash input diff --git a/src/main/java/org/kopi/ebics/letter/E002Letter.java b/src/main/java/org/kopi/ebics/letter/E002Letter.java index 95b7873d..fea9d125 100644 --- a/src/main/java/org/kopi/ebics/letter/E002Letter.java +++ b/src/main/java/org/kopi/ebics/letter/E002Letter.java @@ -22,7 +22,6 @@ import java.security.GeneralSecurityException; import java.util.Locale; -import org.apache.commons.codec.binary.Base64; import org.kopi.ebics.exception.EbicsException; import org.kopi.ebics.interfaces.EbicsUser; @@ -55,7 +54,7 @@ public void create(EbicsUser user) throws GeneralSecurityException, IOException, user.getPartner().getPartnerId(), getString("HIALetter.e002.version"), getString("HIALetter.e002.certificate"), - Base64.encodeBase64(user.getE002Certificate(), true), + chunkedBase64(user.getE002Certificate()), getString("HIALetter.e002.digest"), getHash(user.getE002Certificate())); } diff --git a/src/main/java/org/kopi/ebics/letter/X002Letter.java b/src/main/java/org/kopi/ebics/letter/X002Letter.java index d431ad63..f51fbe60 100644 --- a/src/main/java/org/kopi/ebics/letter/X002Letter.java +++ b/src/main/java/org/kopi/ebics/letter/X002Letter.java @@ -22,7 +22,6 @@ import java.security.GeneralSecurityException; import java.util.Locale; -import org.apache.commons.codec.binary.Base64; import org.kopi.ebics.exception.EbicsException; import org.kopi.ebics.interfaces.EbicsUser; @@ -55,7 +54,7 @@ public void create(EbicsUser user) throws GeneralSecurityException, IOException, user.getPartner().getPartnerId(), getString("HIALetter.x002.version"), getString("HIALetter.x002.certificate"), - Base64.encodeBase64(user.getX002Certificate(), true), + chunkedBase64(user.getX002Certificate()), getString("HIALetter.x002.digest"), getHash(user.getX002Certificate())); } diff --git a/src/main/java/org/kopi/ebics/xml/InitializationRequestElement.java b/src/main/java/org/kopi/ebics/xml/InitializationRequestElement.java index 2c7c11b0..a1b00e73 100644 --- a/src/main/java/org/kopi/ebics/xml/InitializationRequestElement.java +++ b/src/main/java/org/kopi/ebics/xml/InitializationRequestElement.java @@ -21,12 +21,11 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; +import java.util.HexFormat; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; -import org.apache.commons.codec.DecoderException; -import org.apache.commons.codec.binary.Hex; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.kopi.ebics.exception.EbicsException; import org.kopi.ebics.interfaces.EbicsOrderType; @@ -120,8 +119,8 @@ protected byte[] decodeHex(byte[] hex) throws EbicsException { } try { - return Hex.decodeHex(new String(hex).toCharArray()); - } catch (DecoderException e) { + return HexFormat.of().parseHex(new String(hex)); + } catch (IllegalArgumentException e) { throw new EbicsException(e.getMessage()); } } diff --git a/src/main/java/org/kopi/ebics/xml/UploadInitializationRequestElement.java b/src/main/java/org/kopi/ebics/xml/UploadInitializationRequestElement.java index cca19d28..701147b6 100644 --- a/src/main/java/org/kopi/ebics/xml/UploadInitializationRequestElement.java +++ b/src/main/java/org/kopi/ebics/xml/UploadInitializationRequestElement.java @@ -22,9 +22,9 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; +import java.util.Base64; import java.util.Calendar; -import org.apache.commons.codec.binary.Base64; import org.apache.xmlbeans.XmlObject; import org.kopi.ebics.client.EbicsUploadParams; import org.kopi.ebics.exception.EbicsException; @@ -133,7 +133,7 @@ public void buildInitialization() throws EbicsException { String digest; try { // TODO: check if this is correct - digest = Base64.encodeBase64String(MessageDigest.getInstance("SHA-256", "BC").digest(this.userData)); + digest = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256", "BC").digest(this.userData)); } catch (NoSuchAlgorithmException | NoSuchProviderException e) { throw new EbicsException(e); } diff --git a/src/test/java/org/kopi/ebics/CodecMigrationTest.java b/src/test/java/org/kopi/ebics/CodecMigrationTest.java new file mode 100644 index 00000000..249890b4 --- /dev/null +++ b/src/test/java/org/kopi/ebics/CodecMigrationTest.java @@ -0,0 +1,194 @@ +/* + * Copyright Uwe Maurer + */ + +package org.kopi.ebics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.Security; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Locale; + +import org.apache.xml.security.Init; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.kopi.ebics.certificate.KeyUtil; +import org.kopi.ebics.client.Bank; +import org.kopi.ebics.client.Partner; +import org.kopi.ebics.client.User; +import org.kopi.ebics.exception.EbicsException; +import org.kopi.ebics.interfaces.EbicsOrderType; +import org.kopi.ebics.interfaces.InitLetter; +import org.kopi.ebics.letter.A005Letter; +import org.kopi.ebics.session.EbicsSession; +import org.kopi.ebics.xml.InitializationRequestElement; + +/** + * Characterization tests that pin down the byte-exact Base64/Hex encoding + * behavior expected of the letter and request-element code paths. Written + * during the migration from commons-codec to {@link java.util.Base64} / + * {@link java.util.HexFormat} so any future change in those code paths + * stays byte-equivalent. + */ +class CodecMigrationTest { + + @BeforeAll + static void registerBc() { + Init.init(); + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + @Test + void keyUtilDigestIsAsciiUppercaseHexSha256() throws Exception { + RSAPublicKey key = testKey(); + + byte[] digest = KeyUtil.getKeyDigest(key); + + assertEquals(64, digest.length, "SHA-256 hex must be 64 chars"); + for (byte b : digest) { + boolean isDigit = b >= '0' && b <= '9'; + boolean isUpperHex = b >= 'A' && b <= 'F'; + assertTrue(isDigit || isUpperHex, + "digest must be UPPERCASE ASCII hex; got byte " + b); + } + } + + @Test + void keyUtilDigestIsDeterministic() throws Exception { + RSAPublicKey key = testKey(); + assertEquals( + new String(KeyUtil.getKeyDigest(key), StandardCharsets.US_ASCII), + new String(KeyUtil.getKeyDigest(key), StandardCharsets.US_ASCII)); + } + + @Test + void letterCertificateBlockIsChunkedBase64() throws Exception { + String letter = renderA005Letter(testUser()); + + int begin = letter.indexOf("-----BEGIN CERTIFICATE-----"); + int end = letter.indexOf("-----END CERTIFICATE-----"); + assertTrue(begin >= 0 && end > begin, "letter must contain a certificate block"); + + String body = letter.substring(begin + "-----BEGIN CERTIFICATE-----".length(), end).trim(); + String[] lines = body.split("\\r?\\n"); + assertTrue(lines.length >= 2, "chunked Base64 produces multiple lines, got " + lines.length); + for (String line : lines) { + assertTrue(line.length() <= 76, + "chunked Base64 lines must be at most 76 chars, got " + line.length()); + } + + // Pin the trailing CRLF: the END marker must start a fresh line, not + // be glued onto the last Base64 line. JDK's Base64.getMimeEncoder() + // omits the trailing CRLF that commons-codec adds, so chunkedBase64() + // appends it explicitly — this assertion is what catches a regression. + assertTrue(letter.contains("\r\n-----END CERTIFICATE-----") + || letter.contains("\n-----END CERTIFICATE-----"), + "END CERTIFICATE marker must start on a new line"); + } + + @Test + void letterHashIsUppercaseHex() throws Exception { + User user = testUser(); + String letter = renderA005Letter(user); + + String expectedUpper = upperHex( + MessageDigest.getInstance("SHA-256").digest(user.getA005Certificate())); + + String despaced = letter.replaceAll("\\s", ""); + assertTrue(despaced.contains(expectedUpper), + "letter must contain UPPERCASE hex SHA-256 of the DER certificate"); + + // Must not contain the lowercase form — pins case sensitivity that + // existing InitLetterHashTest doesn't enforce. + String expectedLower = expectedUpper.toLowerCase(Locale.ROOT); + assertFalse(despaced.contains(expectedLower), + "letter must use uppercase hex (lowercase form leaked in)"); + } + + @Test + void decodeHexRoundTripsLowercaseHex() throws Exception { + TestableInitElement element = new TestableInitElement(); + byte[] original = new byte[] {0x00, 0x1f, (byte) 0xca, (byte) 0xfe, (byte) 0xba, (byte) 0xbe}; + String hexLower = upperHex(original).toLowerCase(Locale.ROOT); + + byte[] decoded = element.decodeHexForTest(hexLower.getBytes(StandardCharsets.US_ASCII)); + + assertEquals(upperHex(original), upperHex(decoded)); + } + + @Test + void decodeHexThrowsEbicsExceptionOnInvalidInput() throws Exception { + TestableInitElement element = new TestableInitElement(); + assertThrows(EbicsException.class, + () -> element.decodeHexForTest("zz".getBytes(StandardCharsets.US_ASCII))); + } + + // ----- helpers ----- + + private static RSAPublicKey testKey() throws Exception { + // MSB-set 2048-bit modulus so BigInteger.toByteArray() yields a leading + // 0x00 sign byte; KeyUtil.getKeyDigest strips that first byte. + StringBuilder mod = new StringBuilder("C0"); + for (int i = 0; i < 255; i++) { + mod.append(String.format("%02X", i)); + } + BigInteger modulus = new BigInteger(mod.toString(), 16); + BigInteger exponent = BigInteger.valueOf(65537); + return (RSAPublicKey) KeyFactory.getInstance("RSA") + .generatePublic(new RSAPublicKeySpec(modulus, exponent)); + } + + private static User testUser() throws Exception { + Bank bank = new Bank(new URL("https://bank.example/ebics"), "Test Bank", "HOSTID"); + Partner partner = new Partner(bank, "PARTNERID"); + return new User(partner, "USERID", "John Doe", "john@example.com", "DE", "ACME", + "changeit"::toCharArray); + } + + private static String renderA005Letter(User user) throws Exception { + InitLetter letter = new A005Letter(Locale.ENGLISH); + letter.create(user); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + letter.writeTo(out); + return out.toString(); + } + + private static String upperHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02X", b & 0xff)); + } + return sb.toString(); + } + + /** Exposes the protected decodeHex for direct testing. */ + private static final class TestableInitElement extends InitializationRequestElement { + TestableInitElement() { + super((EbicsSession) null, (EbicsOrderType) null, "test"); + } + + @Override + public void buildInitialization() { + // not used + } + + byte[] decodeHexForTest(byte[] hex) throws EbicsException { + return decodeHex(hex); + } + } +} diff --git a/src/test/java/org/kopi/ebics/letter/InitLetterHashTest.java b/src/test/java/org/kopi/ebics/letter/InitLetterHashTest.java index 7e121b2f..4fb4495d 100644 --- a/src/test/java/org/kopi/ebics/letter/InitLetterHashTest.java +++ b/src/test/java/org/kopi/ebics/letter/InitLetterHashTest.java @@ -6,9 +6,9 @@ import java.net.URL; import java.security.MessageDigest; import java.security.Security; +import java.util.HexFormat; import java.util.Locale; -import org.apache.commons.codec.binary.Hex; import org.apache.xml.security.Init; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.Test; @@ -52,8 +52,8 @@ private void assertLetterContainsCertificateHash(InitLetter letter, User user, b // lines; strip whitespace so we can match the contiguous hex digest. String despaced = out.toString().replaceAll("\\s", "").toUpperCase(Locale.ROOT); - String expected = Hex.encodeHexString( - MessageDigest.getInstance("SHA-256").digest(der)).toUpperCase(Locale.ROOT); + String expected = HexFormat.of().withUpperCase().formatHex( + MessageDigest.getInstance("SHA-256").digest(der)); assertTrue(despaced.contains(expected), letter.getClass().getSimpleName() From f537b24349ffe4f4d49f2d3f32b52da6f64ee400 Mon Sep 17 00:00:00 2001 From: Uwe Maurer Date: Wed, 27 May 2026 16:45:08 +0200 Subject: [PATCH 09/14] refactor: replace Apache HttpClient with JDK java.net.http.HttpClient --- pom.xml | 5 - .../kopi/ebics/client/HttpRequestSender.java | 92 ++++----- .../ebics/client/HttpRequestSenderTest.java | 174 ++++++++++++++++++ .../java/org/kopi/ebics/client/StubProxy.java | 145 +++++++++++++++ 4 files changed, 367 insertions(+), 49 deletions(-) create mode 100644 src/test/java/org/kopi/ebics/client/HttpRequestSenderTest.java create mode 100644 src/test/java/org/kopi/ebics/client/StubProxy.java diff --git a/pom.xml b/pom.xml index ae2639cb..d8b5cb53 100644 --- a/pom.xml +++ b/pom.xml @@ -13,11 +13,6 @@ xmlbeans 5.3.0 - - org.apache.httpcomponents - httpclient - 4.5.14 - org.bouncycastle bcprov-jdk18on diff --git a/src/main/java/org/kopi/ebics/client/HttpRequestSender.java b/src/main/java/org/kopi/ebics/client/HttpRequestSender.java index 3f760b81..82abbf71 100644 --- a/src/main/java/org/kopi/ebics/client/HttpRequestSender.java +++ b/src/main/java/org/kopi/ebics/client/HttpRequestSender.java @@ -19,23 +19,16 @@ package org.kopi.ebics.client; import java.io.IOException; -import java.io.InputStream; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; -import org.apache.http.HttpEntity; -import org.apache.http.HttpHeaders; -import org.apache.http.HttpHost; -import org.apache.http.auth.AuthScope; -import org.apache.http.auth.UsernamePasswordCredentials; -import org.apache.http.client.CredentialsProvider; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.entity.EntityBuilder; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.impl.client.BasicCredentialsProvider; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.ProxyAuthenticationStrategy; -import org.apache.http.util.EntityUtils; import org.kopi.ebics.interfaces.Configuration; import org.kopi.ebics.interfaces.ContentFactory; import org.kopi.ebics.io.ByteArrayContentFactory; @@ -48,9 +41,12 @@ */ public class HttpRequestSender { + private static final Duration TIMEOUT = Duration.ofSeconds(300); + private static final String CONTENT_TYPE = "text/xml; charset=ISO-8859-1"; + private final EbicsSession session; private ContentFactory response; - private final CloseableHttpClient httpClient; + private final HttpClient httpClient; /** * Constructs a new HttpRequestSender with a given ebics @@ -63,33 +59,32 @@ public HttpRequestSender(EbicsSession session) { this.httpClient = createClient(); } - private CloseableHttpClient createClient() { - RequestConfig.Builder configBuilder = RequestConfig.copy(RequestConfig.DEFAULT) - .setSocketTimeout(300_000).setConnectTimeout(300_000); + private HttpClient createClient() { + HttpClient.Builder builder = HttpClient.newBuilder().connectTimeout(TIMEOUT); Configuration conf = session.getConfiguration(); String proxyHost = conf.getProperty("http.proxy.host"); - CredentialsProvider credsProvider = null; if (proxyHost != null && !proxyHost.isEmpty()) { int proxyPort = Integer.parseInt(conf.getProperty("http.proxy.port").trim()); - HttpHost proxy = new HttpHost(proxyHost.trim(), proxyPort); - configBuilder.setProxy(proxy); + builder.proxy(ProxySelector.of(new InetSocketAddress(proxyHost.trim(), proxyPort))); String user = conf.getProperty("http.proxy.user"); if (user != null && !user.isEmpty()) { - user = user.trim(); + String trimmedUser = user.trim(); String pwd = conf.getProperty("http.proxy.password").trim(); - credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(new AuthScope(proxyHost, proxyPort), - new UsernamePasswordCredentials(user, pwd)); + builder.authenticator(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + // Only answer proxy challenges — never leak proxy + // credentials to a server-side 401. + if (getRequestorType() != RequestorType.PROXY) { + return null; + } + return new PasswordAuthentication(trimmedUser, pwd.toCharArray()); + } + }); } } - HttpClientBuilder builder = HttpClientBuilder.create() - .setDefaultRequestConfig(configBuilder.build()); - if (credsProvider != null) { - builder.setDefaultCredentialsProvider(credsProvider); - builder.setProxyAuthenticationStrategy(new ProxyAuthenticationStrategy()); - } return builder.build(); } @@ -102,19 +97,28 @@ private CloseableHttpClient createClient() { * @return the HTTP return code */ public final int send(ContentFactory request) throws IOException { - InputStream input = request.getContent(); - HttpPost method = new HttpPost( - session.getUser().getPartner().getBank().getURL().toString()); - - HttpEntity requestEntity = EntityBuilder.create().setStream(input).build(); - method.setEntity(requestEntity); - method.setHeader(HttpHeaders.CONTENT_TYPE, "text/xml; charset=ISO-8859-1"); + URI uri = URI.create(session.getUser().getPartner().getBank().getURL().toString()); + HttpRequest httpRequest = HttpRequest.newBuilder(uri) + .timeout(TIMEOUT) + .header("Content-Type", CONTENT_TYPE) + .POST(HttpRequest.BodyPublishers.ofInputStream(() -> { + try { + return request.getContent(); + } catch (IOException e) { + throw new RuntimeException(e); + } + })) + .build(); - try (CloseableHttpResponse response = httpClient.execute(method)) { - this.response = new ByteArrayContentFactory( - EntityUtils.toByteArray(response.getEntity())); - return response.getStatusLine().getStatusCode(); + HttpResponse httpResponse; + try { + httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("HTTP request interrupted", e); } + this.response = new ByteArrayContentFactory(httpResponse.body()); + return httpResponse.statusCode(); } /** diff --git a/src/test/java/org/kopi/ebics/client/HttpRequestSenderTest.java b/src/test/java/org/kopi/ebics/client/HttpRequestSenderTest.java new file mode 100644 index 00000000..605642ad --- /dev/null +++ b/src/test/java/org/kopi/ebics/client/HttpRequestSenderTest.java @@ -0,0 +1,174 @@ +package org.kopi.ebics.client; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.kopi.ebics.io.ByteArrayContentFactory; +import org.kopi.ebics.session.EbicsSession; +import org.mockito.Mockito; + +/** + * End-to-end test for {@link HttpRequestSender}: spins up an in-process + * {@link HttpServer}, captures what the sender posts, and asserts the + * sender returns the server's response body and status code unmodified. + * + *

Written before migrating from Apache HttpClient 4.x to + * {@link java.net.http.HttpClient} so the contract is pinned independently + * of the underlying client. + */ +class HttpRequestSenderTest { + + private HttpServer server; + private AtomicReference capturedMethod; + private AtomicReference capturedContentType; + private AtomicReference capturedBody; + private volatile int responseStatus; + private volatile byte[] responseBody; + + @BeforeEach + void startServer() throws Exception { + capturedMethod = new AtomicReference<>(); + capturedContentType = new AtomicReference<>(); + capturedBody = new AtomicReference<>(); + responseStatus = 200; + responseBody = "".getBytes(StandardCharsets.UTF_8); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + capturedMethod.set(exchange.getRequestMethod()); + capturedContentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + capturedBody.set(exchange.getRequestBody().readAllBytes()); + exchange.sendResponseHeaders(responseStatus, responseBody.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBody); + } + }); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Test + void postsRequestBodyAndReturnsResponse() throws Exception { + byte[] requestBody = "hello".getBytes(StandardCharsets.UTF_8); + + HttpRequestSender sender = new HttpRequestSender(session(serverUrl())); + int status = sender.send(new ByteArrayContentFactory(requestBody)); + + assertEquals(200, status, "status code must be propagated from server"); + assertEquals("POST", capturedMethod.get(), "must use POST"); + assertEquals("text/xml; charset=ISO-8859-1", capturedContentType.get(), + "Content-Type header must match EBICS expectation"); + assertArrayEquals(requestBody, capturedBody.get(), + "request body bytes must reach the server unchanged"); + assertArrayEquals(responseBody, sender.getResponseBody().getContent().readAllBytes(), + "response body must be exposed via getResponseBody()"); + } + + @Test + void propagatesNonSuccessStatusCodes() throws Exception { + responseStatus = 500; + responseBody = "".getBytes(StandardCharsets.UTF_8); + + HttpRequestSender sender = new HttpRequestSender(session(serverUrl())); + int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8))); + + assertEquals(500, status); + assertArrayEquals(responseBody, sender.getResponseBody().getContent().readAllBytes()); + } + + @Test + void routesThroughConfiguredProxyWithoutAuth() throws Exception { + try (StubProxy proxy = new StubProxy()) { + proxy.enqueueResponse(200, Map.of(), "".getBytes(StandardCharsets.UTF_8)); + + EbicsSession session = proxiedSession(proxy.port(), null, null); + HttpRequestSender sender = new HttpRequestSender(session); + int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8))); + + assertEquals(200, status); + List reqs = proxy.recordedRequests(); + assertEquals(1, reqs.size(), "proxy must receive exactly one request"); + // Going through a proxy, the request line carries the absolute URI. + assertTrue(reqs.get(0).requestLine().contains("http://bank.example/ebics"), + "request line must contain the absolute target URI; got: " + reqs.get(0).requestLine()); + assertFalse(reqs.get(0).headers().containsKey("proxy-authorization"), + "no Proxy-Authorization header expected when no credentials configured"); + } + } + + @Test + void retriesWithProxyAuthorizationAfter407Challenge() throws Exception { + try (StubProxy proxy = new StubProxy()) { + // First connection: challenge with 407 so the client invokes the Authenticator. + proxy.enqueueResponse(407, + Map.of("Proxy-Authenticate", "Basic realm=\"ebics\""), + new byte[0]); + // Second connection: the retry carrying Proxy-Authorization. + proxy.enqueueResponse(200, Map.of(), "".getBytes(StandardCharsets.UTF_8)); + + EbicsSession session = proxiedSession(proxy.port(), "alice", "s3cret"); + HttpRequestSender sender = new HttpRequestSender(session); + int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8))); + + assertEquals(200, status, "client must complete the request after auth retry"); + List reqs = proxy.recordedRequests(); + assertEquals(2, reqs.size(), "proxy must see the original request plus the auth retry"); + assertFalse(reqs.get(0).headers().containsKey("proxy-authorization"), + "first request must go without credentials (challenge-response flow)"); + + String expectedAuth = "Basic " + + Base64.getEncoder().encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8)); + assertEquals(expectedAuth, reqs.get(1).header("Proxy-Authorization"), + "retry must carry Basic Proxy-Authorization derived from configured credentials"); + } + } + + private URL serverUrl() throws Exception { + return new URL("http://127.0.0.1:" + server.getAddress().getPort() + "/"); + } + + private static EbicsSession session(URL url) { + EbicsSession session = Mockito.mock(EbicsSession.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(session.getConfiguration().getProperty(Mockito.anyString())).thenReturn(null); + Mockito.when(session.getUser().getPartner().getBank().getURL()).thenReturn(url); + return session; + } + + private static EbicsSession proxiedSession(int proxyPort, String user, String pass) throws Exception { + EbicsSession session = Mockito.mock(EbicsSession.class, Mockito.RETURNS_DEEP_STUBS); + var conf = session.getConfiguration(); + Mockito.when(conf.getProperty(Mockito.anyString())).thenReturn(null); + Mockito.when(conf.getProperty("http.proxy.host")).thenReturn("127.0.0.1"); + Mockito.when(conf.getProperty("http.proxy.port")).thenReturn(String.valueOf(proxyPort)); + if (user != null) { + Mockito.when(conf.getProperty("http.proxy.user")).thenReturn(user); + Mockito.when(conf.getProperty("http.proxy.password")).thenReturn(pass); + } + // Target host is arbitrary — the stub proxy intercepts everything and never forwards. + Mockito.when(session.getUser().getPartner().getBank().getURL()) + .thenReturn(new URL("http://bank.example/ebics")); + return session; + } +} diff --git a/src/test/java/org/kopi/ebics/client/StubProxy.java b/src/test/java/org/kopi/ebics/client/StubProxy.java new file mode 100644 index 00000000..cbf2ac3c --- /dev/null +++ b/src/test/java/org/kopi/ebics/client/StubProxy.java @@ -0,0 +1,145 @@ +package org.kopi.ebics.client; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * Minimal stub HTTP/1.1 proxy backed by a raw {@link ServerSocket}: records + * each incoming request (request line + headers) and serves canned responses + * from a queue. Used to exercise {@link HttpRequestSender}'s proxy + proxy-auth + * paths without depending on a real proxy implementation. + * + *

Does not forward to any upstream server — every request is "answered" + * locally, which is enough for asserting that the sender contacted the proxy + * and supplied the right {@code Proxy-Authorization} header. + */ +final class StubProxy implements AutoCloseable { + + private final ServerSocket socket; + private final Thread acceptor; + private final List requests = Collections.synchronizedList(new ArrayList<>()); + private final BlockingQueue responses = new LinkedBlockingQueue<>(); + + StubProxy() throws IOException { + this.socket = new ServerSocket(0, 16, InetAddress.getLoopbackAddress()); + this.acceptor = new Thread(this::acceptLoop, "stub-proxy-accept"); + this.acceptor.setDaemon(true); + this.acceptor.start(); + } + + int port() { + return socket.getLocalPort(); + } + + void enqueueResponse(int status, Map headers, byte[] body) { + StringBuilder head = new StringBuilder(); + head.append("HTTP/1.1 ").append(status).append(" Status\r\n"); + for (Map.Entry e : headers.entrySet()) { + head.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n"); + } + head.append("Content-Length: ").append(body.length).append("\r\n"); + head.append("Connection: close\r\n"); + head.append("\r\n"); + byte[] headBytes = head.toString().getBytes(StandardCharsets.ISO_8859_1); + byte[] full = new byte[headBytes.length + body.length]; + System.arraycopy(headBytes, 0, full, 0, headBytes.length); + System.arraycopy(body, 0, full, headBytes.length, body.length); + responses.add(full); + } + + List recordedRequests() { + synchronized (requests) { + return new ArrayList<>(requests); + } + } + + @Override + public void close() throws IOException { + socket.close(); + } + + private void acceptLoop() { + while (!socket.isClosed()) { + try { + Socket client = socket.accept(); + Thread handler = new Thread(() -> handle(client), "stub-proxy-handle"); + handler.setDaemon(true); + handler.start(); + } catch (IOException e) { + // socket closed → loop exits + return; + } + } + } + + private void handle(Socket client) { + try (client; InputStream in = client.getInputStream(); OutputStream out = client.getOutputStream()) { + RecordedRequest req = parseRequest(in); + requests.add(req); + byte[] response = responses.poll(5, TimeUnit.SECONDS); + if (response == null) { + response = "HTTP/1.1 500 No canned response\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .getBytes(StandardCharsets.ISO_8859_1); + } + out.write(response); + out.flush(); + } catch (Exception ignored) { + // Test failure will surface via assertions on the recorded requests. + } + } + + private static RecordedRequest parseRequest(InputStream in) throws IOException { + String requestLine = readLine(in); + Map headers = new LinkedHashMap<>(); + String line; + while (!(line = readLine(in)).isEmpty()) { + int colon = line.indexOf(':'); + if (colon < 0) { + continue; + } + String name = line.substring(0, colon).trim(); + String value = line.substring(colon + 1).trim(); + headers.put(name.toLowerCase(Locale.ROOT), value); + } + // Body is intentionally not drained: for our assertions only the + // request line + headers matter, and HttpClient is happy as long as + // it eventually sees a complete response on this connection. + return new RecordedRequest(requestLine, headers); + } + + private static String readLine(InputStream in) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + int prev = -1; + int ch; + while ((ch = in.read()) != -1) { + if (prev == '\r' && ch == '\n') { + byte[] b = buf.toByteArray(); + return new String(b, 0, b.length - 1, StandardCharsets.ISO_8859_1); + } + buf.write(ch); + prev = ch; + } + return new String(buf.toByteArray(), StandardCharsets.ISO_8859_1); + } + + record RecordedRequest(String requestLine, Map headers) { + String header(String name) { + return headers.get(name.toLowerCase(Locale.ROOT)); + } + } +} From fc09f77371aec84d931d58da5b44b7b5e75cbda5 Mon Sep 17 00:00:00 2001 From: Uwe Maurer Date: Mon, 6 Jul 2026 12:31:09 +0200 Subject: [PATCH 10/14] release 2.1.0 on maven central --- .claude/commands/prepare-github-release.md | 49 +++++++++ .github/release.yml | 22 ++++ .gitignore | 1 + README.md | 57 ++++++++--- pom.xml | 114 ++++++++++++++++++++- release-settings.xml | 17 +++ scripts/github-release.sh | 35 +++++++ scripts/release.sh | 60 +++++++++++ 8 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 .claude/commands/prepare-github-release.md create mode 100644 .github/release.yml create mode 100644 release-settings.xml create mode 100755 scripts/github-release.sh create mode 100755 scripts/release.sh diff --git a/.claude/commands/prepare-github-release.md b/.claude/commands/prepare-github-release.md new file mode 100644 index 00000000..54ee10bf --- /dev/null +++ b/.claude/commands/prepare-github-release.md @@ -0,0 +1,49 @@ +--- +description: Draft and publish a GitHub release for the current pom version, with AI-written categorized notes covering merged PRs AND direct commits. +argument-hint: "[--draft] [version]" +allowed-tools: Bash(git:*), Bash(gh:*), Bash(./mvnw:*), Read, Write +--- + +Prepare a GitHub release for this repository (`ebics-java/ebics-java-client`). Follow these +steps. Do NOT create the tag or release until the user has approved the drafted notes. + +## 1. Determine the version and previous tag +- Version to release: `./mvnw -q help:evaluate -Dexpression=project.version -DforceStdout` + (or use an explicit version passed in `$ARGUMENTS`). The tag is the bare version, e.g. `2.1.0` + (no `v` prefix — match existing tags). The release title is `EBICS Java Version `. +- Previous released tag: the most recent existing tag by version, e.g. + `git tag --sort=-v:refname | head -5` — pick the highest tag that is not the new version. + +## 2. Gather the raw material (this is the whole point — cover EVERYTHING, not just PRs) +- All commits in range, including direct-to-master commits and merges: + `git log ..HEAD --pretty=format:'%h %s (%an)'` +- GitHub's PR-based data (for accurate PR links, @author attribution, new contributors, and the + compare URL): + `gh api repos/ebics-java/ebics-java-client/releases/generate-notes -f tag_name= -f previous_tag_name= -f target_commitish=master --jq '.body'` + +## 3. Draft the notes +Write clean Markdown release notes that: +- Group changes under these headings (omit any that are empty): + `## New Features & Enhancements`, `## Bug Fixes`, `## Dependency Updates`, `## Other Changes`. +- Reword terse commit subjects into clear, user-facing bullets (e.g. drop `feat(cli):` prefixes, + make them read as human sentences). Do not invent changes — every bullet must trace to a real + commit or PR. +- Include direct-to-master commits that have no PR, alongside the PR-based entries. +- Preserve PR links and `@author` attribution where a change came from a PR. +- Keep a `## New Contributors` section and the `**Full Changelog**: ` line from the + generated data if present. +- Lead with a one or two sentence summary of the release if there's a notable theme. + +## 4. Review with the user +Show the full drafted notes in the chat and ask the user to approve or request edits. STOP here +until they approve. Iterate on wording if asked. + +## 5. Publish (only after approval) +- Confirm local `HEAD` is pushed to `origin/master` (`git rev-parse HEAD` == `git rev-parse origin/master`); warn if not. +- Create the tag if it does not exist, then push it: + `git tag ` (skip if it exists) and `git push origin `. +- Write the approved notes to a temp file in the scratchpad, then create the release: + `gh release create --title "EBICS Java Version " --notes-file --verify-tag` +- If `$ARGUMENTS` contains `--draft`, add `--draft` so the user can eyeball it on GitHub before + it goes public. +- Report the release URL that `gh` prints. diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..11e1b509 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,22 @@ +# Controls how GitHub groups the auto-generated release notes +# (used by `gh release create --generate-notes` and the "Generate release notes" button). +# PRs are bucketed by their labels; anything unlabeled falls under "Other changes". +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: New Features & Enhancements + labels: + - enhancement + - feature + - title: Bug Fixes + labels: + - bug + - fix + - title: Dependency Updates + labels: + - dependencies + - title: Other Changes + labels: + - "*" diff --git a/.gitignore b/.gitignore index a799df63..0ee15992 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea *.iml /client/ +/.envrc diff --git a/README.md b/README.md index 2665a523..6b5ecd68 100644 --- a/README.md +++ b/README.md @@ -32,34 +32,59 @@ mvn exec:java \ This mode is useful for containerized or ephemeral environments where `ebics.txt` should not be persisted. -You can build it directly from the source with maven or use the releases from [JitPack](https://jitpack.io/#ebics-java/ebics-java-client/). +The library is published to [Maven Central](https://central.sonatype.com/artifact/io.github.ebics-java/ebics-java-client). -Gradle: +Maven: +``` + + io.github.ebics-java + ebics-java-client + 2.1.0 + ``` -allprojects { - repositories { - ... - maven { url 'https://jitpack.io' } - } -} +Gradle: +``` dependencies { - implementation 'com.github.ebics-java:ebics-java-client:2.0.0' + implementation 'io.github.ebics-java:ebics-java-client:2.1.0' } ``` -Maven + +You can also build it directly from the source with Maven (`./mvnw clean install`). +See [RELEASING.md](RELEASING.md) for how releases are published to Maven Central. + +### Snapshot / unreleased builds via JitPack + +To pull in a specific commit or an unreleased version (any tag, branch, or commit hash) before it +reaches Maven Central, use [JitPack](https://jitpack.io/#ebics-java/ebics-java-client): + +Maven: ``` - - jitpack.io - https://jitpack.io - + + jitpack.io + https://jitpack.io + com.github.ebics-java ebics-java-client - 2.0.0 + master-SNAPSHOT ``` - + +Gradle: +``` +repositories { + maven { url 'https://jitpack.io' } +} + +dependencies { + implementation 'com.github.ebics-java:ebics-java-client:master-SNAPSHOT' +} +``` + +Note that JitPack builds keep the `com.github.ebics-java` group id (derived from the GitHub repo), +whereas released artifacts on Maven Central use `io.github.ebics-java`. + diff --git a/pom.xml b/pom.xml index ae2639cb..9c33016e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,12 +1,38 @@ 4.0.0 - org.kopi - ebics + io.github.ebics-java + ebics-java-client jar - 2.0.0 - ebics + 2.1.0 + EBICS Java Client + EBICS (Electronic Banking Internet Communication Standard) client library for Java. + Supports EBICS 3.0 and French, German and Swiss banks, with a command line client for + bank setup, key initialization and file download. https://github.com/ebics-java/ebics-java-client + + + + GNU Lesser General Public License, Version 2.1 + https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt + repo + + + + + + ebics-java + EBICS Java Client contributors + https://github.com/ebics-java/ebics-java-client/graphs/contributors + + + + + scm:git:https://github.com/ebics-java/ebics-java-client.git + scm:git:git@github.com:ebics-java/ebics-java-client.git + https://github.com/ebics-java/ebics-java-client + HEAD + org.apache.xmlbeans @@ -81,7 +107,13 @@ src/main/xsd/config/config.xsdconfig - + + ebics + org.kopi.ebics.metadata @@ -115,4 +147,76 @@ UTF-8 + + + + + release + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + + attach-javadocs + + jar + + + + + + none + false + true + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + bc + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 + true + + + central + + false + + ${project.groupId}:${project.artifactId}:${project.version} + + + + + + diff --git a/release-settings.xml b/release-settings.xml new file mode 100644 index 00000000..7a8a4b9d --- /dev/null +++ b/release-settings.xml @@ -0,0 +1,17 @@ + + + + + + central + ${env.CENTRAL_USERNAME} + ${env.CENTRAL_PASSWORD} + + + diff --git a/scripts/github-release.sh b/scripts/github-release.sh new file mode 100755 index 00000000..8c67d698 --- /dev/null +++ b/scripts/github-release.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Copyright Uwe Maurer +# +# Tag the current pom version and publish a GitHub release with an auto-generated changelog. +# +# GitHub builds the changelog from the pull requests merged since the previous tag; the +# grouping is configured in .github/release.yml. Run this AFTER the Maven Central release +# (scripts/release.sh) has been published, from a clean, pushed master. +# +# Usage: +# scripts/github-release.sh # tag , push it, create the release +# scripts/github-release.sh --draft # extra flags are forwarded to `gh release create` +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +VERSION="$(./mvnw -q help:evaluate -Dexpression=project.version -DforceStdout)" +TAG="$VERSION" +TITLE="EBICS Java Version $VERSION" + +echo "Releasing $TAG ($TITLE)" + +# Create the tag locally if it does not exist yet, then push it. +if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "Tag $TAG already exists locally." +else + git tag "$TAG" +fi +git push origin "$TAG" + +# Create the GitHub release with notes generated from merged PRs since the previous tag. +gh release create "$TAG" --title "$TITLE" --generate-notes --verify-tag "$@" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..a2816045 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Copyright Uwe Maurer +# +# Publish ebics-java-client to Maven Central (Sonatype Central Portal). +# +# Production credentials live OUTSIDE this repo in a Gradle-format properties file. +# This script reads them, exposes them to Maven as environment variables, and runs the +# `release` profile (Javadoc jar + GPG signing + Central publishing). +# +# A credential-free local build needs none of this -- just run: +# ./mvnw clean install +# +# Usage: +# scripts/release.sh # stage a deployment on the Central Portal +# scripts/release.sh -DskipTests # extra args are forwarded to Maven +# +# Credentials are read from `release.properties` in the repo root, which is git-ignored. +# Create it as a symlink to your out-of-repo credentials file, e.g.: +# ln -s ~/secret/maven-central-gradle.properties release.properties +# +# Override the location with: +# MAVEN_CENTRAL_CREDENTIALS=/path/to/file scripts/release.sh +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CREDS="${MAVEN_CENTRAL_CREDENTIALS:-$REPO_ROOT/release.properties}" + +if [[ ! -f "$CREDS" ]]; then + echo "error: credentials file not found: $CREDS" >&2 + echo " set MAVEN_CENTRAL_CREDENTIALS to point at it, or build locally without" >&2 + echo " publishing using: ./mvnw clean install" >&2 + exit 1 +fi + +# Read one property value from the Gradle-style (key=value) credentials file. +prop() { + local value + value="$(grep -E "^$1=" "$CREDS" | head -1 | cut -d= -f2-)" + if [[ -z "$value" ]]; then + echo "error: property '$1' missing or empty in $CREDS" >&2 + exit 1 + fi + printf '%s' "$value" +} + +# Central Portal token -> consumed by release-settings.xml (). +export CENTRAL_USERNAME="$(prop mavenCentralUsername)" +export CENTRAL_PASSWORD="$(prop mavenCentralPassword)" + +# GPG signing -> consumed by the maven-gpg-plugin BouncyCastle signer. +export MAVEN_GPG_PASSPHRASE="$(prop signingInMemoryKeyPassword)" +# The signing key is stored on a single line with \n-escaped newlines (Gradle in-memory +# format); restore real newlines so the PGP armor parses. +export MAVEN_GPG_KEY="$(prop signingInMemoryKey | sed 's/\\n/\ +/g')" + +cd "$REPO_ROOT" +exec ./mvnw -Prelease -s release-settings.xml clean deploy "$@" From 83129e0bdd193db9d1e9b464182439b15e83b32c Mon Sep 17 00:00:00 2001 From: Trofeomedia <274891666+Trofeomedia@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:39:35 +0200 Subject: [PATCH 11/14] feat(h005): support BTD download orders with service params and date range Downloads sent the 3-letter order code as AdminOrderType with empty StandardOrderParams, which EBICS 3.0 banks reject for customer data. This mirrors the existing BTU upload path: optional EbicsDownloadParams produce AdminOrderType=BTD with a BTDOrderParams/Service block, container type and an optional DateRange. Also fixes the date range that fetchFile(file, orderType, start, end) accepted and dropped: without a service name the legacy order type is kept and the range goes into StandardOrderParams. Behaviour without params is unchanged. Dates are written as plain xs:date; passing a Calendar made XMLBeans append the local offset (2026-08-10+02:00), which shifts the reported day for a bank in another timezone. Co-Authored-By: Claude Opus 5 (1M context) --- .../org/kopi/ebics/client/EbicsClient.java | 16 +- .../ebics/client/EbicsDownloadParams.java | 35 +++++ .../org/kopi/ebics/client/FileTransfer.java | 20 ++- .../ParameterizedEbicsClientLauncher.java | 143 ++++++++++++++++-- .../DownloadInitializationRequestElement.java | 52 ++++++- .../org/kopi/ebics/xml/EbicsXmlFactory.java | 71 ++++++++- .../ParameterizedEbicsClientLauncherTest.java | 96 ++++++++++++ ...nloadInitializationRequestElementTest.java | 110 ++++++++++++++ .../java/org/kopi/ebics/xml/TestSessions.java | 64 ++++++++ 9 files changed, 584 insertions(+), 23 deletions(-) create mode 100644 src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java create mode 100644 src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java create mode 100644 src/test/java/org/kopi/ebics/xml/TestSessions.java diff --git a/src/main/java/org/kopi/ebics/client/EbicsClient.java b/src/main/java/org/kopi/ebics/client/EbicsClient.java index 8479a7f5..454d0f14 100644 --- a/src/main/java/org/kopi/ebics/client/EbicsClient.java +++ b/src/main/java/org/kopi/ebics/client/EbicsClient.java @@ -413,6 +413,17 @@ public void sendFile(File file, EbicsOrderType orderType) throws Exception { public void fetchFile(File file, User user, Product product, EbicsOrderType orderType, boolean isTest) throws IOException, EbicsException { + fetchFile(file, user, product, orderType, null, isTest); + } + + /** + * Downloads a file from the bank. + * + * @param downloadParams optional EBICS 3.0 service parameters and report period; with a + * service name set the order is sent as a BTD business transaction format order + */ + public void fetchFile(File file, User user, Product product, EbicsOrderType orderType, + EbicsDownloadParams downloadParams, boolean isTest) throws IOException, EbicsException { FileTransfer transferManager; EbicsSession session = createSession(user, product); session.addSessionParam("FORMAT", "pain.xxx.cfonb160.dct"); @@ -425,7 +436,7 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde configuration.getTransferTraceDirectory(user)); try { - transferManager.fetchFile(orderType, file); + transferManager.fetchFile(orderType, downloadParams, file); } catch (NoDownloadDataAvailableException e) { // don't log this exception as an error, caller can decide how to handle throw e; @@ -437,7 +448,8 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde public void fetchFile(File file, EbicsOrderType orderType, Date start, Date end) throws IOException, EbicsException { - fetchFile(file, defaultUser, defaultProduct, orderType, false); + fetchFile(file, defaultUser, defaultProduct, orderType, + EbicsDownloadParams.dateRangeOnly(start, end), false); } /** diff --git a/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java b/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java new file mode 100644 index 00000000..83afad4f --- /dev/null +++ b/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java @@ -0,0 +1,35 @@ +package org.kopi.ebics.client; + +import java.util.Date; + +/** + * Service parameters for an EBICS 3.0 (H005) BTD download order. + * + *

With a {@code serviceName} set, the request is sent as {@code AdminOrderType=BTD} with a + * {@code BTDOrderParams/Service} block. With {@code serviceName} left {@code null}, only the + * optional date range is applied and the legacy EBICS 2.x order type is kept, so existing + * callers keep their behaviour. + */ +public record EbicsDownloadParams( + String serviceName, + String scope, + String option, + String messageName, + String messageVersion, + String containerType, + Date startDate, + Date endDate) { + + /** Date-range-only parameters for the legacy (non-BTD) download path. */ + public static EbicsDownloadParams dateRangeOnly(Date startDate, Date endDate) { + if (startDate == null && endDate == null) { + return null; + } + return new EbicsDownloadParams(null, null, null, null, null, null, startDate, endDate); + } + + /** Whether these parameters describe an EBICS 3.0 BTD business transaction format order. */ + public boolean isBtd() { + return serviceName != null; + } +} diff --git a/src/main/java/org/kopi/ebics/client/FileTransfer.java b/src/main/java/org/kopi/ebics/client/FileTransfer.java index 001571f1..c4151144 100644 --- a/src/main/java/org/kopi/ebics/client/FileTransfer.java +++ b/src/main/java/org/kopi/ebics/client/FileTransfer.java @@ -173,9 +173,27 @@ public void sendFile(ContentFactory factory, public void fetchFile(EbicsOrderType orderType, File outputFile) throws IOException, EbicsException + { + fetchFile(orderType, null, outputFile); + } + + /** + * Fetches a file of the given order type from the bank. + * This type of transfer will run until everything is processed. + * No transaction recovery is possible. + * @param orderType type of file to fetch + * @param downloadParams optional EBICS 3.0 service parameters and report period + * @param outputFile where to put the data + * @throws IOException communication error + * @throws EbicsException server generated error + */ + public void fetchFile(EbicsOrderType orderType, + EbicsDownloadParams downloadParams, + File outputFile) + throws IOException, EbicsException { var sender = new HttpRequestSender(session); - var initializer = new DownloadInitializationRequestElement(session, orderType); + var initializer = new DownloadInitializationRequestElement(session, orderType, downloadParams); initializer.build(); initializer.validate(); diff --git a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java index b220222f..c15cfcfb 100644 --- a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java +++ b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java @@ -20,11 +20,18 @@ import java.io.File; import java.net.URL; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.Date; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Locale; +import java.util.Map; import java.util.Properties; import java.util.Set; import org.kopi.ebics.interfaces.EbicsBank; +import org.kopi.ebics.interfaces.EbicsOrderType; import org.kopi.ebics.interfaces.EbicsPartner; import org.kopi.ebics.interfaces.PasswordCallback; import org.kopi.ebics.session.DefaultConfiguration; @@ -41,9 +48,16 @@ public final class ParameterizedEbicsClientLauncher { "--ini", "--hia", "--hpb", - "--help" + "--help", + "--btd" ); + /** + * EBICS 3.0 business transaction downloads always use the admin order type {@code BTD}; the + * business order is carried by the service parameters instead of the 3-letter code. + */ + private static final EbicsOrderType BTD_ORDER_TYPE = () -> "BTD"; + private ParameterizedEbicsClientLauncher() { } @@ -117,6 +131,20 @@ public static void main(String[] args) throws Exception { client.sendHPBRequest(user, product); } + if (parsedArguments.hasFlag("--btd")) { + EbicsDownloadParams downloadParams = btdDownloadParams(parsedArguments); + client.fetchFile( + new File(requireOutputPath(parsedArguments)), + user, + product, + BTD_ORDER_TYPE, + downloadParams, + Boolean.parseBoolean(env("EBICS_TEST_MODE", "false")) + ); + client.quit(); + return; + } + String orderFlag = parsedArguments.firstOrderFlag(); if (orderFlag != null) { OrderType orderType = OrderType.valueOf(orderFlag.substring(2).toUpperCase(Locale.ROOT)); @@ -129,16 +157,15 @@ public static void main(String[] args) throws Exception { defaultUploadParams(user, orderType) ); } else if (parsedArguments.outputPath() != null) { - if (parsedArguments.startDate() != null || parsedArguments.endDate() != null) { - System.err.println( - "Date range arguments are ignored in parameterized mode for this order type." - ); - } client.fetchFile( new File(parsedArguments.outputPath()), user, product, orderType, + EbicsDownloadParams.dateRangeOnly( + parseDate(parsedArguments.startDate(), "--start"), + parseDate(parsedArguments.endDate(), "--end") + ), Boolean.parseBoolean(env("EBICS_TEST_MODE", "false")) ); } @@ -149,12 +176,67 @@ public static void main(String[] args) throws Exception { private static void printUsage() { String usage = "Usage: ParameterizedEbicsClientLauncher [--create] [--ini] [--hia] [--hpb]" - + " [--] [-i inputFile] [-o outputFile]\n" + + " [--] [-i inputFile] [-o outputFile] [-s start] [-e end]\n" + + "EBICS 3.0 download: --btd --service --scope --msg-name " + + " --msg-version --container " + + " [--option ] [-s YYYY-MM-DD] [-e YYYY-MM-DD] -o \n" + + " e.g. --btd --service EOP --scope CH --msg-name camt.053 --msg-version 08" + + " --container ZIP -o statement.zip\n" + "Required environment variables: EBICS_PASSWORD, EBICS_USER_ID, EBICS_PARTNER_ID," + " EBICS_HOST_ID, EBICS_BANK_URL"; System.out.println(usage); } + /** + * Builds the EBICS 3.0 service parameters for {@code --btd}. Fails fast on a missing mandatory + * value, so a half-filled order is never sent to the bank. + */ + static EbicsDownloadParams btdDownloadParams(ParsedArguments parsedArguments) { + // A half date range would be dropped silently further down, which is exactly how a + // catch-up run loses the days it was supposed to fetch. + if ((parsedArguments.startDate() == null) != (parsedArguments.endDate() == null)) { + throw new IllegalArgumentException( + "Options --start and --end must be given together, a single one is ignored" + + " by the bank request"); + } + return new EbicsDownloadParams( + requireOption(parsedArguments.serviceName(), "--service"), + requireOption(parsedArguments.scope(), "--scope"), + parsedArguments.option(), + requireOption(parsedArguments.messageName(), "--msg-name"), + requireOption(parsedArguments.messageVersion(), "--msg-version"), + requireOption(parsedArguments.containerType(), "--container"), + parseDate(parsedArguments.startDate(), "--start"), + parseDate(parsedArguments.endDate(), "--end") + ); + } + + static String requireOutputPath(ParsedArguments parsedArguments) { + return requireOption(parsedArguments.outputPath(), "-o"); + } + + private static String requireOption(String value, String option) { + String normalized = normalize(value); + if (normalized == null) { + throw new IllegalArgumentException("Missing required option " + option + " for --btd"); + } + return normalized; + } + + private static Date parseDate(String value, String option) { + String normalized = normalize(value); + if (normalized == null) { + return null; + } + try { + return Date.from(LocalDate.parse(normalized) + .atStartOfDay(ZoneId.systemDefault()).toInstant()); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + "Option " + option + " expects a date as YYYY-MM-DD but was: " + normalized); + } + } + private static EbicsUploadParams defaultUploadParams(User user, OrderType orderType) { if (orderType == OrderType.XE2) { var orderParams = new EbicsUploadParams.OrderParams( @@ -290,6 +372,7 @@ static String normalize(String value) { static final class ParsedArguments { private final Set flags = new LinkedHashSet<>(); + private final Map values; private final String inputPath; private final String outputPath; private final String startDate; @@ -297,20 +380,33 @@ static final class ParsedArguments { private ParsedArguments( Set flags, + Map values, String inputPath, String outputPath, String startDate, String endDate ) { this.flags.addAll(flags); + this.values = Map.copyOf(values); this.inputPath = inputPath; this.outputPath = outputPath; this.startDate = startDate; this.endDate = endDate; } + /** Value options of the EBICS 3.0 service block; each consumes the following argument. */ + private static final Set VALUE_OPTIONS = Set.of( + "--service", + "--scope", + "--option", + "--msg-name", + "--msg-version", + "--container" + ); + static ParsedArguments parse(String[] args) { Set flags = new LinkedHashSet<>(); + Map values = new LinkedHashMap<>(); String inputPath = null; String outputPath = null; String startDate = null; @@ -337,12 +433,17 @@ static ParsedArguments parse(String[] args) { endDate = requireValue(args, ++index, arg); continue; } + String lowered = arg.toLowerCase(Locale.ROOT); + if (VALUE_OPTIONS.contains(lowered)) { + values.put(lowered, requireValue(args, ++index, arg)); + continue; + } if (arg.startsWith("--")) { - flags.add(arg.toLowerCase(Locale.ROOT)); + flags.add(lowered); } } } - return new ParsedArguments(flags, inputPath, outputPath, startDate, endDate); + return new ParsedArguments(flags, values, inputPath, outputPath, startDate, endDate); } private static String requireValue(String[] args, int index, String option) { @@ -393,5 +494,29 @@ String startDate() { String endDate() { return endDate; } + + String serviceName() { + return values.get("--service"); + } + + String scope() { + return values.get("--scope"); + } + + String option() { + return values.get("--option"); + } + + String messageName() { + return values.get("--msg-name"); + } + + String messageVersion() { + return values.get("--msg-version"); + } + + String containerType() { + return values.get("--container"); + } } } diff --git a/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java b/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java index df1d29cc..53021288 100644 --- a/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java +++ b/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java @@ -20,8 +20,12 @@ import java.util.Calendar; +import org.apache.xmlbeans.SchemaType; +import org.apache.xmlbeans.XmlObject; +import org.kopi.ebics.client.EbicsDownloadParams; import org.kopi.ebics.exception.EbicsException; import org.kopi.ebics.interfaces.EbicsOrderType; +import org.kopi.ebics.schema.h005.BTDOrderParamsDocument; import org.kopi.ebics.schema.h005.EbicsRequestDocument.EbicsRequest; import org.kopi.ebics.schema.h005.EbicsRequestDocument.EbicsRequest.Body; import org.kopi.ebics.schema.h005.EbicsRequestDocument.EbicsRequest.Header; @@ -52,7 +56,21 @@ public class DownloadInitializationRequestElement extends InitializationRequestE */ public DownloadInitializationRequestElement(EbicsSession session, EbicsOrderType type) { + this(session, type, null); + } + + /** + * Constructs a new DInitializationRequestElement for downloads initializations. + * @param session the current ebics session + * @param type the download order type (FDL, HTD, HPD) + * @param downloadParams optional service parameters; with a service name set the request is + * sent as an EBICS 3.0 BTD order, otherwise the legacy order type is kept + */ + public DownloadInitializationRequestElement(EbicsSession session, + EbicsOrderType type, + EbicsDownloadParams downloadParams) { super(session, type, generateName(type)); + this.downloadParams = downloadParams; } @Override @@ -78,16 +96,39 @@ public void buildInitialization() throws EbicsException { decodeHex(session.getUser().getPartner().getBank().getE002Digest())); bankPubKeyDigests = EbicsXmlFactory.createBankPubKeyDigests(authentication, encryption); - StandardOrderParamsType standardOrderParamsType = EbicsXmlFactory.createStandardOrderParamsType(); - var type = StaticHeaderOrderDetailsType.AdminOrderType.Factory.newInstance(); - type.setStringValue(this.getType()); + + XmlObject orderParamsType; + SchemaType orderParamsSchema; + + if (downloadParams != null && downloadParams.isBtd()) { + // EBICS 3.0: the business transaction goes into the service block, the admin order + // type is always BTD. + type.setStringValue("BTD"); + orderParamsType = EbicsXmlFactory.createBTDParams( + downloadParams.serviceName(), downloadParams.scope(), downloadParams.option(), + downloadParams.messageName(), downloadParams.messageVersion(), + downloadParams.containerType(), downloadParams.startDate(), + downloadParams.endDate()); + orderParamsSchema = BTDOrderParamsDocument.type; + } else { + type.setStringValue(this.getType()); + StandardOrderParamsType standardOrderParamsType = + EbicsXmlFactory.createStandardOrderParamsType(); + if (downloadParams != null + && downloadParams.startDate() != null && downloadParams.endDate() != null) { + standardOrderParamsType.setDateRange(EbicsXmlFactory.createDateRange( + downloadParams.startDate(), downloadParams.endDate())); + } + orderParamsType = standardOrderParamsType; + orderParamsSchema = StandardOrderParamsDocument.type; + } //FIXME Some banks cannot handle OrderID element in download process. Add parameter in configuration!!! orderDetails = EbicsXmlFactory.createStaticHeaderOrderDetailsType(null,//session.getUser().getPartner().nextOrderId(), type, - standardOrderParamsType, - StandardOrderParamsDocument.type); + orderParamsType, + orderParamsSchema); xstatic = EbicsXmlFactory.createStaticHeaderType(session.getBankID(), nonce, @@ -107,5 +148,6 @@ public void buildInitialization() throws EbicsException { document = EbicsXmlFactory.createEbicsRequestDocument(request); } + private final EbicsDownloadParams downloadParams; private static final long serialVersionUID = 3776072549761880272L; } diff --git a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java index ddd8137d..d5c33351 100644 --- a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java +++ b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java @@ -18,6 +18,7 @@ package org.kopi.ebics.xml; +import java.time.ZoneId; import java.util.Calendar; import java.util.Date; @@ -33,8 +34,11 @@ import org.ebics.s002.UserSignatureDataDocument; import org.ebics.s002.UserSignatureDataSigBookType; import org.kopi.ebics.schema.h005.AuthenticationPubKeyInfoType; +import org.kopi.ebics.schema.h005.BTDParamsType; import org.kopi.ebics.schema.h005.BTUOrderParamsDocument; import org.kopi.ebics.schema.h005.BTUParamsType; +import org.kopi.ebics.schema.h005.ContainerStringType; +import org.kopi.ebics.schema.h005.DateType; import org.kopi.ebics.schema.h005.DataDigestType; import org.kopi.ebics.schema.h005.DataEncryptionInfoType.EncryptionPubKeyDigest; import org.kopi.ebics.schema.h005.DataTransferRequestType; @@ -920,6 +924,65 @@ public static BTUParamsType createBTUParams(String serviceName, String scope, St return type; } + /** + * Creates the order parameters of an EBICS 3.0 (H005) BTD download order. + * + * @param serviceName the BTF service code, e.g. {@code EOP} + * @param scope the rule scope, e.g. {@code CH}; may be {@code null} + * @param option the service option; may be {@code null} + * @param messageName the message name, e.g. {@code camt.053} + * @param messageVersion the message version, e.g. {@code 08} + * @param containerType the container type ({@code XML}, {@code ZIP} or {@code SVC}); + * may be {@code null} + * @param start the start of the requested report period; may be {@code null} + * @param end the end of the requested report period; may be {@code null} + * @return the BTDParamsType XML object + */ + public static BTDParamsType createBTDParams(String serviceName, String scope, String option, + String messageName, String messageVersion, String containerType, Date start, Date end) { + var type = BTDParamsType.Factory.newInstance(); + var service = type.addNewService(); + service.setServiceName(serviceName); + if (scope != null) { + service.setScope(scope); + } + if (option != null) { + service.setServiceOption(option); + } + if (containerType != null) { + // The container flag lives inside Service (not directly in BTDParamsType) and the + // generated setter takes the enum, not a String. + var container = ContainerStringType.Enum.forString(containerType); + if (container == null) { + throw new IllegalArgumentException( + "Unsupported EBICS container type: " + containerType); + } + service.addNewContainer().setContainerType(container); + } + var msgType = MessageType.Factory.newInstance(); + msgType.setStringValue(messageName); + msgType.setVersion(messageVersion); + service.setMsgName(msgType); + if (start != null && end != null) { + var range = type.addNewDateRange(); + range.xsetStart(toXmlDate(start)); + range.xsetEnd(toXmlDate(end)); + } + return type; + } + + /** + * Converts a date into an xs:date value without a timezone offset. Passing a + * {@link Calendar} instead would make XMLBeans append the local offset (e.g. + * {@code 2026-08-10+02:00}), which shifts the reported day for a bank in another timezone. + */ + private static DateType toXmlDate(Date date) { + var value = DateType.Factory.newInstance(); + value.setStringValue( + date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + return value; + } + // private static StaticHeaderOrderDetailsType createStaticHeaderOrderDetailsType(String orderId, // OrderAttributeType.Enum orderAttribute, OrderType orderType, XmlObject orderParams, // QName newInstance) { @@ -979,13 +1042,9 @@ public static StandardOrderParamsType createStandardOrderParamsType() { */ public static StandardOrderParamsType.DateRange createDateRange(Date start, Date end) { StandardOrderParamsType.DateRange newDateRange = StandardOrderParamsType.DateRange.Factory.newInstance(); - Calendar startRange = Calendar.getInstance(); - Calendar endRange = Calendar.getInstance(); - startRange.setTime(start); - endRange.setTime(end); - newDateRange.setStart(startRange); - newDateRange.setEnd(endRange); + newDateRange.xsetStart(toXmlDate(start)); + newDateRange.xsetEnd(toXmlDate(end)); return newDateRange; } diff --git a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java index 5efaee4a..f436691f 100644 --- a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java +++ b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java @@ -1,6 +1,7 @@ package org.kopi.ebics.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -39,6 +40,101 @@ void rejectsMissingOptionValue() { assertTrue(exception.getMessage().contains("Missing value for option -o")); } + @Test + void parsesBtdServiceOptions() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP", + "-s", "2026-08-10", "-e", "2026-08-11", "-o", "statement.zip" + } + ); + + assertTrue(parsed.hasFlag("--btd")); + assertNull(parsed.firstOrderFlag(), "--btd is reserved and must not be read as order type"); + + var params = ParameterizedEbicsClientLauncher.btdDownloadParams(parsed); + + assertEquals("EOP", params.serviceName()); + assertEquals("CH", params.scope()); + assertEquals("camt.053", params.messageName()); + assertEquals("08", params.messageVersion()); + assertEquals("ZIP", params.containerType()); + assertNull(params.option()); + assertNotNull(params.startDate()); + assertNotNull(params.endDate()); + assertEquals("statement.zip", ParameterizedEbicsClientLauncher.requireOutputPath(parsed)); + } + + @Test + void rejectsBtdWithoutMandatoryServiceValues() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ "--btd", "--service", "EOP", "-o", "statement.zip" } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) + ); + assertTrue( + exception.getMessage().contains("Missing required option --scope for --btd"), + "Expected a clear abort naming the missing option, got: " + exception.getMessage() + ); + } + + @Test + void rejectsBtdWithoutOutputPath() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP" + } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.requireOutputPath(parsed) + ); + assertTrue(exception.getMessage().contains("Missing required option -o for --btd")); + } + + @Test + void rejectsMalformedDateRange() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP", + "-s", "10.08.2026", "-e", "2026-08-11" + } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) + ); + assertTrue(exception.getMessage().contains("--start expects a date as YYYY-MM-DD")); + } + + @Test + void rejectsHalfDateRange() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP", "-s", "2026-08-10" + } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) + ); + assertTrue( + exception.getMessage().contains("--start and --end must be given together"), + "A half date range must abort instead of being dropped silently: " + + exception.getMessage() + ); + } + @Test void normalizeHandlesBlankValues() { assertNull(ParameterizedEbicsClientLauncher.normalize(" ")); diff --git a/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java b/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java new file mode 100644 index 00000000..47f610aa --- /dev/null +++ b/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java @@ -0,0 +1,110 @@ +package org.kopi.ebics.xml; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import org.apache.xmlbeans.XmlError; +import org.apache.xmlbeans.XmlOptions; +import org.junit.jupiter.api.Test; +import org.kopi.ebics.client.EbicsDownloadParams; + +class DownloadInitializationRequestElementTest { + + @Test + void buildsBtdRequestWithSwissCamt053ServiceParams() throws Exception { + var params = new EbicsDownloadParams( + "EOP", "CH", null, "camt.053", "08", "ZIP", + localDate(2026, 8, 10), + localDate(2026, 8, 11)); + + String raw = TestSessions.buildDownloadInitializationXml(params); + System.out.println("=== BTD download initialization request ==="); + System.out.println(raw); + System.out.println("=== end of request ==="); + + String xml = stripNamespacePrefixes(raw); + + assertTrue(xml.contains("BTD"), + "EBICS 3.0 verlangt BTD als AdminOrderType, nicht den 3-Buchstaben-Code"); + assertTrue(xml.contains("EOP")); + assertTrue(xml.contains("CH")); + assertTrue(xml.contains(">camt.053<"), "MsgName fehlt"); + assertTrue(xml.matches("(?s).*]*version=\"08\".*"), "MsgName-Version fehlt"); + assertTrue(xml.matches("(?s).*]*containerType=\"ZIP\".*"), "Container fehlt"); + assertTrue(xml.contains(""), + "Ohne DateRange kann der naechtliche Job verpasste Tage nicht nachholen"); + assertTrue(xml.contains("2026-08-10"), + "DateRange-Start muss ein reines xs:date ohne Zeitzonen-Offset sein"); + assertTrue(xml.contains("2026-08-11"), + "DateRange-Ende muss ein reines xs:date ohne Zeitzonen-Offset sein"); + } + + /** + * Der Kalendertag wird lokal gebildet, damit die Behauptung in jeder Zeitzone haelt. + * Fixe Epoch-Millis wuerden westlich von UTC auf den Vortag rutschen. + */ + private static Date localDate(int year, int month, int day) { + return Date.from(LocalDate.of(year, month, day) + .atStartOfDay(ZoneId.systemDefault()).toInstant()); + } + + /** Der Auftragsparameter-Block muss gegen das H005-Schema gueltig sein, sonst lehnt die Bank ab. */ + @Test + void btdOrderParamsAreSchemaValid() { + var params = EbicsXmlFactory.createBTDParams("EOP", "CH", null, "camt.053", "08", "ZIP", + localDate(2026, 8, 10), localDate(2026, 8, 11)); + + var errors = new ArrayList(); + boolean valid = params.validate(new XmlOptions().setErrorListener(errors)); + + assertTrue(valid, "BTDOrderParams ist nicht schemakonform: " + errors); + } + + /** Ein unbekannter Container-Typ muss abbrechen statt still zu verschwinden. */ + @Test + void rejectsUnknownContainerType() { + assertThrows(IllegalArgumentException.class, () -> EbicsXmlFactory.createBTDParams( + "EOP", "CH", null, "camt.053", "08", "TAR", null, null)); + } + + /** Ohne Service-Parameter muss der EBICS-2.x-Pfad unveraendert bleiben. */ + @Test + void keepsLegacyRequestUnchangedWithoutParams() throws Exception { + String xml = stripNamespacePrefixes(TestSessions.buildDownloadInitializationXml(null)); + + assertTrue(xml.contains("C53"), + "Ohne Parameter bleibt der 3-Buchstaben-Code der AdminOrderType"); + assertFalse(xml.contains("BTDOrderParams"), "Ohne Parameter darf kein BTD-Block entstehen"); + assertFalse(xml.contains(""), "Ohne Datumsbereich darf kein DateRange entstehen"); + } + + /** + * EbicsClient.fetchFile(file, orderType, start, end) hat den Datumsbereich bisher verworfen. + * Auf dem EBICS-2.x-Pfad landet er jetzt in StandardOrderParams, der Auftragstyp bleibt. + */ + @Test + void appliesDateRangeOnLegacyPathWithoutTurningIntoBtd() throws Exception { + var params = EbicsDownloadParams.dateRangeOnly( + localDate(2026, 8, 10), localDate(2026, 8, 11)); + + String xml = stripNamespacePrefixes(TestSessions.buildDownloadInitializationXml(params)); + + assertTrue(xml.contains("C53"), + "Ohne Service-Namen darf kein BTD-Auftrag daraus werden"); + assertFalse(xml.contains("BTDOrderParams"), "Ohne Service-Namen kein BTD-Block"); + assertTrue(xml.contains("2026-08-10"), + "Der Datumsbereich muss in der Anfrage landen, nicht verworfen werden"); + assertTrue(xml.contains("2026-08-11")); + } + + /** XMLBeans waehlt Namensraum-Praefixe frei; die duerfen den Test nicht kippen. */ + private static String stripNamespacePrefixes(String xml) { + return xml.replaceAll("<(/?)[A-Za-z0-9_.-]+:", "<$1") + .replaceAll("\\s+xmlns(:[A-Za-z0-9_.-]+)?=\"[^\"]*\"", ""); + } +} diff --git a/src/test/java/org/kopi/ebics/xml/TestSessions.java b/src/test/java/org/kopi/ebics/xml/TestSessions.java new file mode 100644 index 00000000..7b155df3 --- /dev/null +++ b/src/test/java/org/kopi/ebics/xml/TestSessions.java @@ -0,0 +1,64 @@ +package org.kopi.ebics.xml; + +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.security.Security; + +import org.apache.xml.security.Init; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.kopi.ebics.client.EbicsDownloadParams; +import org.kopi.ebics.session.EbicsSession; +import org.kopi.ebics.session.OrderType; + +/** + * Test helper that builds EBICS request elements against a stubbed session, so the generated + * XML can be asserted without a bank, keystore or persisted workspace. + */ +final class TestSessions { + + /** 32 bytes worth of hex characters; the production code hex-decodes the bank digests. */ + private static final byte[] DUMMY_DIGEST = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .getBytes(StandardCharsets.US_ASCII); + + static { + Init.init(); + Security.addProvider(new BouncyCastleProvider()); + } + + private TestSessions() { + } + + /** + * Builds the download initialization request for the given service parameters and returns the + * canonical XML. + * + * @param params the EBICS 3.0 service parameters, or {@code null} for the legacy path + * @return the generated request XML + */ + static String buildDownloadInitializationXml(EbicsDownloadParams params) throws Exception { + var element = new DownloadInitializationRequestElement(stubSession(), OrderType.C53, params); + element.buildInitialization(); + return element.toPrettyString(); + } + + private static EbicsSession stubSession() throws Exception { + var session = mock(EbicsSession.class, RETURNS_DEEP_STUBS); + when(session.getBankID()).thenReturn("EBICSHOST"); + when(session.getProduct().getLanguage()).thenReturn("de"); + when(session.getProduct().getName()).thenReturn("test-product"); + when(session.getConfiguration().getAuthenticationVersion()).thenReturn("X002"); + when(session.getConfiguration().getEncryptionVersion()).thenReturn("E002"); + when(session.getConfiguration().getRevision()).thenReturn(1); + when(session.getConfiguration().getVersion()).thenReturn("H005"); + when(session.getUser().getUserId()).thenReturn("USER0001"); + when(session.getUser().getSecurityMedium()).thenReturn("0000"); + when(session.getUser().getPartner().getPartnerId()).thenReturn("PARTNER1"); + when(session.getUser().getPartner().getBank().getX002Digest()).thenReturn(DUMMY_DIGEST); + when(session.getUser().getPartner().getBank().getE002Digest()).thenReturn(DUMMY_DIGEST); + return session; + } +} From d1a02df036d6ca5d8a56f14fff7a83afca163bf1 Mon Sep 17 00:00:00 2001 From: Trofeomedia <274891666+Trofeomedia@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:13:24 +0200 Subject: [PATCH 12/14] fix(h005): validate download arguments before any bank contact, use LocalDate Review round 1 on the BTD download support. - Reject a partial or reversed date range in the EbicsDownloadParams constructor, the one place every caller passes through. A half range used to be dropped when the request was built, on the launcher's legacy path without even a warning; a reversed range is schema-valid and comes back as EBICS_NO_DOWNLOAD_DATA_AVAILABLE, indistinguishable from a genuinely empty period. - Check every launcher argument before the first environment read, keystore access or bank call. It ran after loadUser/createUser and after --ini/--hia/--hpb, so an incomplete --btd order could still fire an INI request first, and INI is one-shot at most banks. - Carry the report period as LocalDate instead of Date. A calendar day read out of an instant depends on the machine's timezone: a UTC-midnight Date becomes the previous day west of UTC. The Date-taking overloads are kept and now document that. Adds createDateRange(LocalDate, LocalDate). - Upper-case the EBICS code list values (--service, --scope, --option, --container) so --container zip no longer aborts. Message names such as camt.053 stay as given. Tests 31 -> 36. New guards were each seen failing first. Co-Authored-By: Claude Opus 5 (1M context) --- .../org/kopi/ebics/client/EbicsClient.java | 15 +++- .../ebics/client/EbicsDownloadParams.java | 29 +++++- .../ParameterizedEbicsClientLauncher.java | 68 ++++++++++---- .../DownloadInitializationRequestElement.java | 4 +- .../org/kopi/ebics/xml/EbicsXmlFactory.java | 49 +++++++--- .../ParameterizedEbicsClientLauncherTest.java | 90 ++++++++++++++++++- ...nloadInitializationRequestElementTest.java | 33 +++++-- 7 files changed, 244 insertions(+), 44 deletions(-) diff --git a/src/main/java/org/kopi/ebics/client/EbicsClient.java b/src/main/java/org/kopi/ebics/client/EbicsClient.java index 454d0f14..b1ca0cc8 100644 --- a/src/main/java/org/kopi/ebics/client/EbicsClient.java +++ b/src/main/java/org/kopi/ebics/client/EbicsClient.java @@ -58,6 +58,7 @@ import org.kopi.ebics.session.OrderType; import org.kopi.ebics.session.Product; import org.kopi.ebics.utils.Constants; +import org.kopi.ebics.xml.EbicsXmlFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -446,10 +447,22 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde } } + /** + * Downloads a file for a report period. + * + *

A {@link Date} is an instant, the EBICS report period is a pair of calendar days. + * The calendar day is therefore read in the timezone of the machine running this code, so a + * {@code Date} at UTC midnight becomes the previous day in any zone west of UTC. Prefer + * {@link #fetchFile(File, User, Product, EbicsOrderType, EbicsDownloadParams, boolean)} with + * {@link java.time.LocalDate} values, which has no timezone in it. + */ public void fetchFile(File file, EbicsOrderType orderType, Date start, Date end) throws IOException, EbicsException { fetchFile(file, defaultUser, defaultProduct, orderType, - EbicsDownloadParams.dateRangeOnly(start, end), false); + EbicsDownloadParams.dateRangeOnly( + start == null ? null : EbicsXmlFactory.toLocalDate(start), + end == null ? null : EbicsXmlFactory.toLocalDate(end)), + false); } /** diff --git a/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java b/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java index 83afad4f..9a701fec 100644 --- a/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java +++ b/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java @@ -1,6 +1,6 @@ package org.kopi.ebics.client; -import java.util.Date; +import java.time.LocalDate; /** * Service parameters for an EBICS 3.0 (H005) BTD download order. @@ -9,6 +9,15 @@ * {@code BTDOrderParams/Service} block. With {@code serviceName} left {@code null}, only the * optional date range is applied and the legacy EBICS 2.x order type is kept, so existing * callers keep their behaviour. + * + *

The report period is a pair of calendar days ({@link LocalDate}), not instants: EBICS sends + * it as {@code xs:date}, and a timezone in that position only creates off-by-one-day bugs. + * + *

The constructor rejects a partial or reversed range. Both would otherwise travel silently: + * a half range is dropped when the request is built, and a reversed one is schema-valid and comes + * back as "no data available", which is indistinguishable from a period that really was empty. + * This is the single place every caller passes through, so the check lives here rather than in + * each caller. */ public record EbicsDownloadParams( String serviceName, @@ -17,11 +26,23 @@ public record EbicsDownloadParams( String messageName, String messageVersion, String containerType, - Date startDate, - Date endDate) { + LocalDate startDate, + LocalDate endDate) { + + public EbicsDownloadParams { + if ((startDate == null) != (endDate == null)) { + throw new IllegalArgumentException( + "startDate and endDate must be given together (--start/--end); a single one" + + " would be dropped from the bank request"); + } + if (startDate != null && endDate.isBefore(startDate)) { + throw new IllegalArgumentException( + "endDate must not be before startDate, got " + startDate + " to " + endDate); + } + } /** Date-range-only parameters for the legacy (non-BTD) download path. */ - public static EbicsDownloadParams dateRangeOnly(Date startDate, Date endDate) { + public static EbicsDownloadParams dateRangeOnly(LocalDate startDate, LocalDate endDate) { if (startDate == null && endDate == null) { return null; } diff --git a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java index c15cfcfb..08d6918d 100644 --- a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java +++ b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java @@ -68,6 +68,11 @@ public static void main(String[] args) throws Exception { return; } + // Every argument is checked before the first environment read, keystore access or bank + // call. INI is one-shot at most banks: aborting on a missing --container after the INI + // request has gone out would leave a half-initialised access behind. + validateArguments(parsedArguments); + String passphrase = requiredEnv("EBICS_PASSWORD"); String userId = requiredEnv("EBICS_USER_ID"); String partnerId = requiredEnv("EBICS_PARTNER_ID"); @@ -162,10 +167,7 @@ public static void main(String[] args) throws Exception { user, product, orderType, - EbicsDownloadParams.dateRangeOnly( - parseDate(parsedArguments.startDate(), "--start"), - parseDate(parsedArguments.endDate(), "--end") - ), + legacyDownloadParams(parsedArguments), Boolean.parseBoolean(env("EBICS_TEST_MODE", "false")) ); } @@ -187,25 +189,50 @@ private static void printUsage() { System.out.println(usage); } + /** + * Rejects every unusable argument combination before the program talks to anyone. Nothing here + * touches the network, the filesystem or the environment. + */ + static void validateArguments(ParsedArguments parsedArguments) { + if (parsedArguments.hasFlag("--btd")) { + btdDownloadParams(parsedArguments); + requireOutputPath(parsedArguments); + } else { + legacyDownloadParams(parsedArguments); + } + } + /** * Builds the EBICS 3.0 service parameters for {@code --btd}. Fails fast on a missing mandatory - * value, so a half-filled order is never sent to the bank. + * value, so a half-filled order is never sent to the bank. The date range pair itself is + * checked by {@link EbicsDownloadParams}, which covers every other caller too. */ static EbicsDownloadParams btdDownloadParams(ParsedArguments parsedArguments) { - // A half date range would be dropped silently further down, which is exactly how a - // catch-up run loses the days it was supposed to fetch. - if ((parsedArguments.startDate() == null) != (parsedArguments.endDate() == null)) { - throw new IllegalArgumentException( - "Options --start and --end must be given together, a single one is ignored" - + " by the bank request"); - } return new EbicsDownloadParams( - requireOption(parsedArguments.serviceName(), "--service"), - requireOption(parsedArguments.scope(), "--scope"), - parsedArguments.option(), + upperCase(requireOption(parsedArguments.serviceName(), "--service")), + upperCase(requireOption(parsedArguments.scope(), "--scope")), + upperCase(parsedArguments.option()), requireOption(parsedArguments.messageName(), "--msg-name"), requireOption(parsedArguments.messageVersion(), "--msg-version"), - requireOption(parsedArguments.containerType(), "--container"), + upperCase(requireOption(parsedArguments.containerType(), "--container")), + parseDate(parsedArguments.startDate(), "--start"), + parseDate(parsedArguments.endDate(), "--end") + ); + } + + /** + * Service code, scope, service option and container type are EBICS code list values and are + * always upper case. Message names like {@code camt.053} are not, and stay untouched. + */ + private static String upperCase(String value) { + return value == null ? null : value.toUpperCase(Locale.ROOT); + } + + /** + * Builds the date-range-only parameters of the legacy (EBICS 2.x) download path. + */ + static EbicsDownloadParams legacyDownloadParams(ParsedArguments parsedArguments) { + return EbicsDownloadParams.dateRangeOnly( parseDate(parsedArguments.startDate(), "--start"), parseDate(parsedArguments.endDate(), "--end") ); @@ -223,14 +250,17 @@ private static String requireOption(String value, String option) { return normalized; } - private static Date parseDate(String value, String option) { + /** + * Parses a {@code YYYY-MM-DD} argument into a calendar day. No timezone is involved, so the + * day the user typed is the day that reaches the bank, wherever the job runs. + */ + private static LocalDate parseDate(String value, String option) { String normalized = normalize(value); if (normalized == null) { return null; } try { - return Date.from(LocalDate.parse(normalized) - .atStartOfDay(ZoneId.systemDefault()).toInstant()); + return LocalDate.parse(normalized); } catch (DateTimeParseException e) { throw new IllegalArgumentException( "Option " + option + " expects a date as YYYY-MM-DD but was: " + normalized); diff --git a/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java b/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java index 53021288..2c102ee8 100644 --- a/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java +++ b/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java @@ -115,8 +115,8 @@ public void buildInitialization() throws EbicsException { type.setStringValue(this.getType()); StandardOrderParamsType standardOrderParamsType = EbicsXmlFactory.createStandardOrderParamsType(); - if (downloadParams != null - && downloadParams.startDate() != null && downloadParams.endDate() != null) { + // EbicsDownloadParams guarantees the range is either absent or complete. + if (downloadParams != null && downloadParams.startDate() != null) { standardOrderParamsType.setDateRange(EbicsXmlFactory.createDateRange( downloadParams.startDate(), downloadParams.endDate())); } diff --git a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java index d5c33351..0e479f99 100644 --- a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java +++ b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java @@ -18,6 +18,7 @@ package org.kopi.ebics.xml; +import java.time.LocalDate; import java.time.ZoneId; import java.util.Calendar; import java.util.Date; @@ -934,12 +935,15 @@ public static BTUParamsType createBTUParams(String serviceName, String scope, St * @param messageVersion the message version, e.g. {@code 08} * @param containerType the container type ({@code XML}, {@code ZIP} or {@code SVC}); * may be {@code null} - * @param start the start of the requested report period; may be {@code null} - * @param end the end of the requested report period; may be {@code null} + * @param start the first calendar day of the requested report period; may be + * {@code null} + * @param end the last calendar day of the requested report period; may be + * {@code null} * @return the BTDParamsType XML object */ public static BTDParamsType createBTDParams(String serviceName, String scope, String option, - String messageName, String messageVersion, String containerType, Date start, Date end) { + String messageName, String messageVersion, String containerType, + LocalDate start, LocalDate end) { var type = BTDParamsType.Factory.newInstance(); var service = type.addNewService(); service.setServiceName(serviceName); @@ -972,14 +976,15 @@ public static BTDParamsType createBTDParams(String serviceName, String scope, St } /** - * Converts a date into an xs:date value without a timezone offset. Passing a - * {@link Calendar} instead would make XMLBeans append the local offset (e.g. - * {@code 2026-08-10+02:00}), which shifts the reported day for a bank in another timezone. + * Converts a calendar day into an xs:date value. No timezone is involved in + * either direction: setting a {@link Calendar} would make XMLBeans append the local offset + * (e.g. {@code 2026-08-10+02:00}), which shifts the reported day for a bank in another + * timezone, and converting through an instant would make the day itself depend on the + * machine's zone. */ - private static DateType toXmlDate(Date date) { + private static DateType toXmlDate(LocalDate date) { var value = DateType.Factory.newInstance(); - value.setStringValue( - date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + value.setStringValue(date.toString()); return value; } @@ -1034,13 +1039,29 @@ public static StandardOrderParamsType createStandardOrderParamsType() { } /** - * Creates a new DateRange XML object + * Creates a new DateRange XML object. + * + *

A {@link Date} is an instant, the EBICS date range is a pair of calendar days. + * The calendar day is therefore taken in the timezone of the machine running this code: a + * {@code Date} at UTC midnight becomes the previous day in any zone west of UTC. Prefer + * {@link #createDateRange(LocalDate, LocalDate)} — that overload has no timezone in it. * * @param start the start range * @param end the end range * @return the DateRange XML object */ public static StandardOrderParamsType.DateRange createDateRange(Date start, Date end) { + return createDateRange(toLocalDate(start), toLocalDate(end)); + } + + /** + * Creates a new DateRange XML object from two calendar days. + * + * @param start the first day of the range + * @param end the last day of the range + * @return the DateRange XML object + */ + public static StandardOrderParamsType.DateRange createDateRange(LocalDate start, LocalDate end) { StandardOrderParamsType.DateRange newDateRange = StandardOrderParamsType.DateRange.Factory.newInstance(); newDateRange.xsetStart(toXmlDate(start)); @@ -1049,6 +1070,14 @@ public static StandardOrderParamsType.DateRange createDateRange(Date start, Date return newDateRange; } + /** + * Reads the calendar day out of an instant, in the timezone of this machine. Only for the + * {@link Date}-based compatibility overloads; anything new should carry a {@link LocalDate}. + */ + public static LocalDate toLocalDate(Date date) { + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + // /** // * Creates a new FileFormatType XML object // * diff --git a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java index f436691f..45a0e9e3 100644 --- a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java +++ b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import org.junit.jupiter.api.Test; @@ -129,12 +130,99 @@ void rejectsHalfDateRange() { () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) ); assertTrue( - exception.getMessage().contains("--start and --end must be given together"), + exception.getMessage().contains("must be given together"), "A half date range must abort instead of being dropped silently: " + exception.getMessage() ); } + /** + * I-1: the legacy (non-BTD) path dropped a half date range silently as well, and the former + * System.err warning was gone. Aborting beats warning: a catch-up run that believes it asked + * for a period but did not is the exact failure this order type exists to prevent. + */ + @Test + void rejectsHalfDateRangeOnLegacyPathToo() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ "--c53", "-o", "auszug.xml", "-s", "2026-08-01" } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.legacyDownloadParams(parsed) + ); + assertTrue( + exception.getMessage().contains("must be given together"), + "The legacy path must not silently drop a half date range: " + exception.getMessage() + ); + } + + /** M-4: a reversed range is schema-valid and indistinguishable from "no data available". */ + @Test + void rejectsReversedDateRange() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP", + "-s", "2026-08-11", "-e", "2026-08-10" + } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) + ); + assertTrue( + exception.getMessage().contains("must not be before"), + "Expected the reversed range to be named: " + exception.getMessage() + ); + } + + /** M-1: the container type is an EBICS code list value, casing is not the user's problem. */ + @Test + void normalizesCaseOfServiceCodes() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "eop", "--scope", "ch", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "zip" + } + ); + + var params = ParameterizedEbicsClientLauncher.btdDownloadParams(parsed); + + assertEquals("ZIP", params.containerType(), "--container zip must not abort"); + assertEquals("EOP", params.serviceName(), "service codes are upper case in EBICS"); + assertEquals("CH", params.scope(), "the scope is an ISO country or issuer code"); + assertEquals("camt.053", params.messageName(), "message names stay as given"); + } + + /** + * I-3: every argument has to be checked before anything reaches the bank. The guard used to sit + * after loadUser/createUser and after --ini/--hia/--hpb, so an incomplete --btd order could + * still fire an INI request first, and INI is one-shot at most banks. + * + *

Proven by ordering: with no EBICS_* environment set, main() must fail on the argument, not + * on the environment variable it reads later. + */ + @Test + void validatesArgumentsBeforeAnyBankContact() { + assumeTrue(System.getenv("EBICS_PASSWORD") == null, + "needs an environment without live EBICS credentials"); + + Exception exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.main(new String[]{ + "--ini", "--btd", "--service", "EOP", "--scope", "CH", + "--msg-name", "camt.053", "--msg-version", "08", "-o", "statement.zip" + }) + ); + assertTrue( + exception.getMessage().contains("Missing required option --container"), + "Arguments must be rejected before the first environment read or bank call, got: " + + exception.getMessage() + ); + } + @Test void normalizeHandlesBlankValues() { assertNull(ParameterizedEbicsClientLauncher.normalize(" ")); diff --git a/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java b/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java index 47f610aa..49b4c976 100644 --- a/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java +++ b/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java @@ -5,9 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDate; -import java.time.ZoneId; import java.util.ArrayList; -import java.util.Date; +import java.util.TimeZone; import org.apache.xmlbeans.XmlError; import org.apache.xmlbeans.XmlOptions; import org.junit.jupiter.api.Test; @@ -45,12 +44,32 @@ void buildsBtdRequestWithSwissCamt053ServiceParams() throws Exception { } /** - * Der Kalendertag wird lokal gebildet, damit die Behauptung in jeder Zeitzone haelt. - * Fixe Epoch-Millis wuerden westlich von UTC auf den Vortag rutschen. + * I-2: der Kalendertag darf nicht an der Zeitzone des Rechners haengen. Frueher lief er als + * {@code Date} durch {@code ZoneId.systemDefault()} und wurde westlich von UTC zum Vortag. */ - private static Date localDate(int year, int month, int day) { - return Date.from(LocalDate.of(year, month, day) - .atStartOfDay(ZoneId.systemDefault()).toInstant()); + @Test + void keepsTheCalendarDayInAnyMachineTimezone() { + var original = TimeZone.getDefault(); + try { + for (String zone : new String[]{ + "Europe/Zurich", "America/Los_Angeles", "Pacific/Kiritimati", "UTC" }) { + TimeZone.setDefault(TimeZone.getTimeZone(zone)); + + var params = EbicsXmlFactory.createBTDParams("EOP", "CH", null, "camt.053", "08", + "ZIP", LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 11)); + + assertTrue(params.xmlText().contains(">2026-08-10<"), + "Der Starttag muss in " + zone + " derselbe sein: " + params.xmlText()); + assertFalse(params.xmlText().contains("2026-08-09"), + "Tagesversatz in " + zone + ": " + params.xmlText()); + } + } finally { + TimeZone.setDefault(original); + } + } + + private static LocalDate localDate(int year, int month, int day) { + return LocalDate.of(year, month, day); } /** Der Auftragsparameter-Block muss gegen das H005-Schema gueltig sein, sonst lehnt die Bank ab. */ From fcac99ae56aa3e6d3903976ca5b155216dd8cd07 Mon Sep 17 00:00:00 2001 From: Uwe Maurer Date: Tue, 25 Aug 2026 14:58:51 +0200 Subject: [PATCH 13/14] make getRootDir configurable via system property - add fetchFile with defaultUser --- .../org/kopi/ebics/client/EbicsClient.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/org/kopi/ebics/client/EbicsClient.java b/src/main/java/org/kopi/ebics/client/EbicsClient.java index b1ca0cc8..94a9ae6b 100644 --- a/src/main/java/org/kopi/ebics/client/EbicsClient.java +++ b/src/main/java/org/kopi/ebics/client/EbicsClient.java @@ -69,7 +69,20 @@ * */ public class EbicsClient { + /** + * The workspace holding ebics.txt, the user keys and the order numbers. + * + *

Overridable through EBICS_ROOT_DIR, the same variable {@link + * ParameterizedEbicsClientLauncher} uses, so that a second access (a test bank, a second + * agreement) can be initialized without writing into the workspace of the first one. The keys + * are not the only thing at stake: quitting saves the incremented order number, which the bank + * counts. + */ private static File getRootDir() { + String configured = System.getenv("EBICS_ROOT_DIR"); + if (configured != null && !configured.isBlank()) { + return new File(configured.trim()); + } return new File(System.getProperty("user.home"), "ebics" + File.separator + "client"); } @@ -417,6 +430,18 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde fetchFile(file, user, product, orderType, null, isTest); } + /** + * Downloads a file for the default user, with EBICS 3.0 service parameters. + * + *

The counterpart of {@link #sendFile(File, EbicsOrderType, EbicsUploadParams)}: callers + * that use the default user have no other way to reach the BTD path, since the default + * product is not exposed. + */ + public void fetchFile(File file, EbicsOrderType orderType, EbicsDownloadParams downloadParams) + throws IOException, EbicsException { + fetchFile(file, defaultUser, defaultProduct, orderType, downloadParams, false); + } + /** * Downloads a file from the bank. * From 9e45631deadc0bf1c56d331c556df20f2153b2d3 Mon Sep 17 00:00:00 2001 From: Uwe Maurer Date: Tue, 25 Aug 2026 15:11:39 +0200 Subject: [PATCH 14/14] release 2.2.0 --- README.md | 4 ++-- pom.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6b5ecd68..4b7f4e88 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,14 @@ Maven: io.github.ebics-java ebics-java-client - 2.1.0 + 2.2.0 ``` Gradle: ``` dependencies { - implementation 'io.github.ebics-java:ebics-java-client:2.1.0' + implementation 'io.github.ebics-java:ebics-java-client:2.2.0' } ``` diff --git a/pom.xml b/pom.xml index 3209ef8e..8f3c38d4 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ io.github.ebics-java ebics-java-client jar - 2.1.0 + 2.2.0 EBICS Java Client EBICS (Electronic Banking Internet Communication Standard) client library for Java. Supports EBICS 3.0 and French, German and Swiss banks, with a command line client for