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/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..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;
@@ -45,29 +44,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"),
+ chunkedBase64(user.getA005Certificate()),
+ 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..1905f26c 100644
--- a/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java
+++ b/src/main/java/org/kopi/ebics/letter/AbstractInitLetter.java
@@ -23,16 +23,14 @@
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.Base64;
import java.util.Date;
+import java.util.HexFormat;
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,46 +98,34 @@ 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
*/
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();
}
- 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;
+ /**
+ * 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.
@@ -215,9 +201,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..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;
@@ -45,29 +44,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"),
+ chunkedBase64(user.getE002Certificate()),
+ 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..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;
@@ -45,29 +44,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"),
+ chunkedBase64(user.getX002Certificate()),
+ getString("HIALetter.x002.digest"),
+ getHash(user.getX002Certificate()));
}
@Override
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/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());
}
}
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 "));
+ }
+}
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..4fb4495d
--- /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.HexFormat;
+import java.util.Locale;
+
+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 = HexFormat.of().withUpperCase().formatHex(
+ MessageDigest.getInstance("SHA-256").digest(der));
+
+ 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);
+ }
+}