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 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/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/pom.xml b/pom.xml index 2b9d2e3d..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 @@ -26,7 +21,7 @@ org.bouncycastle bcprov-jdk18on - 1.82 + 1.84 org.slf4j @@ -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/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/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/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/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) { + } +} 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..b220222f --- /dev/null +++ b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java @@ -0,0 +1,397 @@ +/* + * 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"), + 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 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/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); + } +}