Add DSC-based MFA: enrollment, login factor, JWS helper, tenant-scoped serving

Backend:
- m_bank_user_dsc + m_dsc_challenge tables (Liquibase 0292/0293/0294)
- DSC enrollment flow: start → JNLP → JWS helper → callback → upsert
- DSC login factor: challenge → sign → verify → claim minted TFA token
- 2FA gate now also accepts an active DSC mapping (AuthenticationApiResource
  + TwoFactorAuthenticationFilter), defensively wrapped so a missing DSC table
  doesn't 500 the auth path
- TenantAwareBasicAuthenticationFilter exempts /api/v*/dsc/helper.jar and
  strips Authorization header for that path so JWS's raw GET works without
  the user seeing a browser Basic-auth prompt
- DSC helper JAR served at /api/v*/dsc/helper.jar from a configurable
  filesystem path (default ~/.cbsnexora/dsc-helper.jar)

Java helper JAR (dsc-helper/):
- Standalone Gradle subproject, Java 8 target, fat jar with Gson shaded in
- Cross-platform token picker (Windows Cert Store / ePass2003 / eToken /
  ProxKey / Other) — matches the JNLP arg contract emitted by the backend
- Sets Fineract-Platform-TenantId on its callback POST from the JNLP args
- JNLP age check now reads cbsnexora-dsc.jnlp (current name) AND
  SignTender.jnlp (legacy) — picks the youngest, never blocks if none exist

Inline KYC upsert in updateClient (mirror of the create-flow path):
- ClientKycWritePlatformService.upsertKycInline() — creates or merges KYC
  fields, bumps status to IN_PROGRESS on real changes; resets PAN verification
  on PAN edit of a verified record
- ClientWritePlatformServiceJpaRepositoryImpl.updateClient() handles the
  kyc:{} sub-object on PUT /clients/{id}, matching createClient

User uniqueness:
- m_appuser.email + m_appuser.mobile_no UNIQUE (Liquibase 0295)
- Pre-flight assertEmailAndMobileUnique() in create/update with self-exclusion
- handleDataIntegrityIssues maps the two new constraints to clean
  field-scoped error messages (race-condition safety net)

Email OTP subject line now: "Your OTP for Login - <AppName> (dd-MM-yy-HH:mm)"
where AppName comes from fineract.email.from-name (default "CBSnexora").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-12 11:13:19 +05:30
co-authored by Claude Opus 4.7
parent 5e3212dc54
commit 8284851f67
51 changed files with 3805 additions and 5 deletions
@@ -0,0 +1,419 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*/
package com.cbsnexora.dsc;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import javax.swing.*;
import javax.swing.table.DefaultTableModel;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* Entry point launched by Java Web Start. Reads its single argument as JSON:
* <pre>
* {
* "randomNo": "...",
* "mode": "ENROLL" | "SIGN_CHALLENGE",
* "callbackUrl": "https://.../api/v1/dsc/{enroll|login/dsc}/{rn}/{callback|verify}",
* "challenge": "<hex>", // SIGN_CHALLENGE only
* "expectedSerial": "<hex>", // optional — locks selection to a single cert
* "title": "...",
* "codebase": "..."
* }
* </pre>
*
* <p>Windows uses the OS keystore ({@code Windows-MY}). Linux/Mac falls back to a PKCS11 token
* driver the user points at via {@link TokenFrame}.
*
* <p>Server is the source of truth for what gets persisted — this helper extracts and POSTs;
* the server parses, validates, and stores.
*/
public final class CbsnexoraDscHelper {
/** A freshly-launched JNLP must be less than this many minutes old. Prevents stale-link reuse. */
private static final long MAX_LINK_AGE_MINUTES = 5;
private static HelperArgs cfg;
private static KeyStore keyStore;
private static Provider pkcs11Provider; // Linux/Mac
public static void main(final String[] args) {
try {
if (!validateJnlpAge()) {
showError("This signing link has expired. Please request a fresh one from the portal.",
"Link Expired");
System.exit(1);
}
if (args == null || args.length < 1 || args[0] == null || args[0].isEmpty()) {
showError("Helper launched without arguments.", "Bad Launch");
System.exit(1);
}
cfg = HelperArgs.parse(args[0]);
// Token picker is now shown on every OS — Windows users can pick either the OS-native
// certificate store or a PKCS11 .dll for vendor-specific USB tokens (ePass2003, eToken,
// ProxKey, etc.). Linux / macOS only see PKCS11 options.
SwingUtilities.invokeLater(CbsnexoraDscHelper::launchTokenPicker);
} catch (final Exception e) {
e.printStackTrace();
showError("Unexpected error: " + e.getMessage(), "Error");
System.exit(1);
}
}
// ── Token picker (cross-platform) ───────────────────────────────────────
private static void launchTokenPicker() {
final TokenFrame tokenFrame = new TokenFrame();
tokenFrame.setProceedAction(selection -> {
try {
if (TokenFrame.SELECTION_WINDOWS_MY.equals(selection)) {
// OS-native keystore — no PIN dialog needed; the platform handles authn.
keyStore = KeyStore.getInstance("Windows-MY");
keyStore.load(null, null);
} else {
// PKCS11 library — drives a Swing PIN dialog via the callback handler.
initializePkcs11(selection);
keyStore = loadPkcs11KeyStore();
if (keyStore == null) {
showError("Failed to open token keystore. Wrong PIN?", "Login Failed");
return;
}
}
tokenFrame.dispose();
showCertSelectionWindow();
} catch (final Exception e) {
e.printStackTrace();
showError("Could not open keystore: " + e.getMessage(), "Token Error");
}
});
tokenFrame.setVisible(true);
}
private static void initializePkcs11(final String libraryPath) throws Exception {
final String config = "name = CBSnexoraDSC\nlibrary = " + libraryPath + "\n";
final ByteArrayInputStream in = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8));
try {
// Java 9+
final Class<?> sunPkcs11 = Class.forName("sun.security.pkcs11.SunPKCS11");
pkcs11Provider = (Provider) sunPkcs11.getConstructor(InputStream.class).newInstance(in);
} catch (final ClassNotFoundException e) {
// Fallback — same path; preserved for old JREs that resolve the class differently.
pkcs11Provider = (Provider) Class.forName("sun.security.pkcs11.SunPKCS11")
.getConstructor(InputStream.class).newInstance(in);
}
Security.removeProvider(pkcs11Provider.getName());
Security.addProvider(pkcs11Provider);
}
private static KeyStore loadPkcs11KeyStore() throws Exception {
final AuthProvider authProvider = (AuthProvider) pkcs11Provider;
final KeyStore.CallbackHandlerProtection protection = new KeyStore.CallbackHandlerProtection(
new SwingPasswordCallbackHandler());
final KeyStore.Builder builder = KeyStore.Builder.newInstance("PKCS11", authProvider, protection);
return builder.getKeyStore();
}
// ── Certificate selection UI (shared between Windows and PKCS11) ────────
private static void showCertSelectionWindow() {
final JFrame frame = new JFrame(cfg.title != null ? cfg.title : "Select Digital Signature Certificate");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setLayout(new BorderLayout(10, 10));
final DefaultTableModel model = new DefaultTableModel(
new Object[] { "Common Name", "Serial No", "Issuer", "Expires" }, 0) {
@Override
public boolean isCellEditable(final int row, final int column) {
return false;
}
};
final JTable table = new JTable(model);
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
table.getColumnModel().getColumn(0).setPreferredWidth(180);
table.getColumnModel().getColumn(1).setPreferredWidth(180);
table.getColumnModel().getColumn(2).setPreferredWidth(260);
table.getColumnModel().getColumn(3).setPreferredWidth(100);
final SimpleDateFormat dateFmt = new SimpleDateFormat("dd-MMM-yyyy");
// Fully qualified — java.awt.List exists too and collides with the wildcard import.
final java.util.List<String> aliasList = new ArrayList<>();
try {
final Enumeration<String> en = keyStore.aliases();
while (en.hasMoreElements()) {
final String alias = en.nextElement();
final java.security.cert.Certificate cert = keyStore.getCertificate(alias);
if (!(cert instanceof X509Certificate)) {
continue;
}
final X509Certificate x = (X509Certificate) cert;
final String serial = x.getSerialNumber().toString(16).toUpperCase(Locale.ROOT);
// SIGN_CHALLENGE with expectedSerial → only show the one cert the server expects.
if ("SIGN_CHALLENGE".equals(cfg.mode) && cfg.expectedSerial != null
&& !cfg.expectedSerial.equalsIgnoreCase(serial)) {
continue;
}
model.addRow(new Object[] {
extractCN(x.getSubjectX500Principal().getName()),
serial,
x.getIssuerX500Principal().getName(),
dateFmt.format(x.getNotAfter())
});
aliasList.add(alias);
}
} catch (final KeyStoreException e) {
e.printStackTrace();
showError("Could not read certificates: " + e.getMessage(), "Keystore Error");
return;
}
if (model.getRowCount() == 0) {
showError("SIGN_CHALLENGE".equals(cfg.mode)
? "No DSC matching the expected serial was found in this keystore."
: "No certificates were found in the keystore.", "Nothing to Sign");
System.exit(1);
}
final JButton proceed = new JButton(cfg.mode.equals("ENROLL") ? "Map Selected DSC" : "Sign with Selected DSC");
proceed.setEnabled(false);
table.getSelectionModel().addListSelectionListener(e -> proceed.setEnabled(table.getSelectedRow() >= 0));
final JButton cancel = new JButton("Cancel");
cancel.addActionListener(e -> System.exit(0));
proceed.addActionListener(e -> {
final int row = table.getSelectedRow();
if (row < 0) {
return;
}
final String alias = aliasList.get(row);
try {
if ("ENROLL".equals(cfg.mode)) {
performEnroll(alias);
} else if ("SIGN_CHALLENGE".equals(cfg.mode)) {
performSignChallenge(alias);
} else {
showError("Unknown mode: " + cfg.mode, "Bad Mode");
return;
}
JOptionPane.showMessageDialog(frame, "Done. You can close this window and return to the browser.",
"Success", JOptionPane.INFORMATION_MESSAGE);
frame.dispose();
System.exit(0);
} catch (final Exception ex) {
ex.printStackTrace();
showError("Operation failed: " + ex.getMessage(), "Error");
}
});
final JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 10));
buttons.add(cancel);
buttons.add(proceed);
frame.add(new JScrollPane(table), BorderLayout.CENTER);
frame.add(buttons, BorderLayout.SOUTH);
frame.setSize(880, 420);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
// ── ENROLL: send the cert (base64 DER) up to the server ─────────────────
private static void performEnroll(final String alias) throws Exception {
final X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
final String certB64 = Base64.getEncoder().encodeToString(cert.getEncoded());
final Map<String, String> body = new LinkedHashMap<>();
body.put("certB64", certB64);
body.put("dscName", extractCN(cert.getSubjectX500Principal().getName()));
body.put("dscType", "SIGNING");
postJson(cfg.callbackUrl, body);
}
// ── SIGN_CHALLENGE: RSA-SHA256(challenge bytes) → POST signature ────────
private static void performSignChallenge(final String alias) throws Exception {
final PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, null);
if (privateKey == null) {
throw new GeneralSecurityException("Private key for alias " + alias + " is not accessible");
}
final X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
// Sanity: chain + cert exist (also surfaces token issues early).
final Certificate[] chain = keyStore.getCertificateChain(alias);
if (chain == null || chain.length == 0) {
throw new GeneralSecurityException("Cert chain for alias " + alias + " is missing");
}
final Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(cfg.challenge.getBytes(StandardCharsets.UTF_8));
final String signatureB64 = Base64.getEncoder().encodeToString(sig.sign());
final Map<String, String> body = new LinkedHashMap<>();
body.put("certSerial", cert.getSerialNumber().toString(16).toUpperCase(Locale.ROOT));
body.put("signatureB64", signatureB64);
postJson(cfg.callbackUrl, body);
}
// ── HTTP helpers ────────────────────────────────────────────────────────
private static void postJson(final String urlStr, final Object body) throws IOException {
final String json = new Gson().toJson(body);
final URL url = new URL(urlStr);
final HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setRequestProperty("Accept", "application/json");
// The helper runs outside the browser (JWS-launched JVM process) so it doesn't get the
// tenant header from any HTTP interceptor — pass it explicitly from the JNLP args. The
// backend's TenantAwareBasicAuthenticationFilter requires this header (or a
// ?tenantIdentifier= query param) on every /api/* request.
if (cfg != null && cfg.tenantIdentifier != null && !cfg.tenantIdentifier.isEmpty()) {
conn.setRequestProperty("Fineract-Platform-TenantId", cfg.tenantIdentifier);
}
conn.setDoOutput(true);
try (OutputStream out = conn.getOutputStream()) {
out.write(json.getBytes(StandardCharsets.UTF_8));
}
final int code = conn.getResponseCode();
// Read body for diagnostics; surface non-2xx to the user.
final String resp;
try (InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
ByteArrayOutputStream sink = new ByteArrayOutputStream()) {
if (in != null) {
final byte[] buf = new byte[2048];
int n;
while ((n = in.read(buf)) > 0) {
sink.write(buf, 0, n);
}
}
resp = sink.toString("UTF-8");
}
if (code < 200 || code >= 300) {
throw new IOException("Server returned HTTP " + code + ": " + resp);
}
}
// ── JNLP age check — same shape as your existing tender helper ──────────
private static boolean validateJnlpAge() {
// Look for whatever filename our UI uses today AND the legacy name. If any candidate
// exists and is recent, accept. If none exist, accept (some browsers stream JNLP directly
// to JWS without saving). We only fail if a candidate exists AND is too old AND no fresh
// candidate is present — the server-side challenge TTL is the real authority on staleness.
try {
final String[] candidates = { "cbsnexora-dsc.jnlp", "SignTender.jnlp" };
final String userProfile = System.getenv("USERPROFILE");
final String downloads = (userProfile != null ? userProfile : System.getProperty("user.home"))
+ File.separator + "Downloads";
boolean anyExist = false;
long youngestMinutes = Long.MAX_VALUE;
for (final String name : candidates) {
final File f = new File(downloads, name);
if (f.exists()) {
anyExist = true;
final long m = (System.currentTimeMillis() - f.lastModified()) / 60_000L;
if (m < youngestMinutes) {
youngestMinutes = m;
}
}
}
// No JNLP on disk → JWS likely streamed it directly. Don't block.
if (!anyExist) {
return true;
}
return youngestMinutes <= MAX_LINK_AGE_MINUTES;
} catch (final Exception ignored) {
return true; // never block on env weirdness
}
}
// ── Misc ────────────────────────────────────────────────────────────────
private static String extractCN(final String dn) {
if (dn == null) {
return "";
}
for (final String part : dn.split(",")) {
final String[] kv = part.trim().split("=", 2);
if (kv.length == 2 && "CN".equalsIgnoreCase(kv[0].trim())) {
return kv[1].trim();
}
}
return dn;
}
private static void showError(final String message, final String title) {
JOptionPane.showMessageDialog(null, message, title, JOptionPane.ERROR_MESSAGE);
}
/** Parsed JSON-arg payload. Mutable for convenience; not exposed beyond this class. */
static final class HelperArgs {
String randomNo;
String mode; // ENROLL | SIGN_CHALLENGE
String callbackUrl;
String challenge; // SIGN_CHALLENGE only
String expectedSerial; // optional
String codebase;
String tenantIdentifier; // Fineract tenant — sent as Fineract-Platform-TenantId on callback
String title;
static HelperArgs parse(final String json) {
final JsonElement root = JsonParser.parseString(json);
if (!root.isJsonObject()) {
throw new IllegalArgumentException("argument is not a JSON object");
}
final JsonObject o = root.getAsJsonObject();
final HelperArgs a = new HelperArgs();
a.randomNo = optString(o, "randomNo");
a.mode = optString(o, "mode");
a.callbackUrl = optString(o, "callbackUrl");
a.challenge = optString(o, "challenge");
a.expectedSerial = optString(o, "expectedSerial");
a.codebase = optString(o, "codebase");
a.tenantIdentifier = optString(o, "tenantIdentifier");
a.title = optString(o, "title");
if (a.mode == null || a.callbackUrl == null || a.randomNo == null) {
throw new IllegalArgumentException("randomNo, mode, callbackUrl are required");
}
if ("SIGN_CHALLENGE".equals(a.mode) && (a.challenge == null || a.challenge.isEmpty())) {
throw new IllegalArgumentException("challenge is required for SIGN_CHALLENGE mode");
}
return a;
}
private static String optString(final JsonObject o, final String k) {
return o.has(k) && !o.get(k).isJsonNull() ? o.get(k).getAsString() : null;
}
}
private CbsnexoraDscHelper() {
}
}
@@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*/
package com.cbsnexora.dsc;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.swing.*;
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.util.concurrent.CancellationException;
/**
* Pops a Swing dialog asking for the PKCS11 token PIN. Used by the {@code KeyStore.Builder} on
* Linux/Mac when opening a hardware-token keystore — Windows-MY doesn't need it (the OS keystore
* handles auth via the platform UI).
*/
public class SwingPasswordCallbackHandler implements CallbackHandler {
@Override
public void handle(final Callback[] callbacks) throws IOException, UnsupportedCallbackException {
for (final Callback cb : callbacks) {
if (cb instanceof PasswordCallback) {
handlePasswordCallback((PasswordCallback) cb);
} else {
throw new UnsupportedCallbackException(cb, "Callback not supported: " + cb.getClass().getName());
}
}
}
private void handlePasswordCallback(final PasswordCallback passCb) {
final JPanel panel = new JPanel(new GridLayout(2, 1, 5, 5));
panel.add(new JLabel(passCb.getPrompt() != null ? passCb.getPrompt() : "Enter token PIN:"));
final JPasswordField pin = new JPasswordField(20);
panel.add(pin);
final JOptionPane pane = new JOptionPane(panel, JOptionPane.QUESTION_MESSAGE, JOptionPane.OK_CANCEL_OPTION);
final JDialog dialog = pane.createDialog(null, "Token PIN");
dialog.addWindowListener(new WindowAdapter() {
@Override
public void windowOpened(final WindowEvent e) {
pin.requestFocusInWindow();
}
});
dialog.setVisible(true);
dialog.dispose();
final Object val = pane.getValue();
final int choice = val instanceof Integer ? (Integer) val : JOptionPane.CANCEL_OPTION;
if (choice == JOptionPane.OK_OPTION) {
passCb.setPassword(pin.getPassword());
} else {
// Mirrors the original tender helper: user cancel → exit. Throwing the cancel up to
// KeyStore.Builder would leave the JNLP launch hanging.
throw new CancellationException("Token PIN entry cancelled by user");
}
}
}
@@ -0,0 +1,167 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*/
package com.cbsnexora.dsc;
import javax.swing.*;
import java.awt.*;
import java.io.File;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
/**
* Cross-platform DSC token / store picker. On Windows the first entry is "Windows Certificate
* Store" (which routes to the {@code Windows-MY} keystore — what most Indian DSC USB tokens auto-
* register with). Below that are common PKCS11 libraries for ePass2003 / eToken / ProxKey / etc.,
* plus an "Other" option that takes a custom {@code .dll} / {@code .so} / {@code .dylib} path.
*
* <p>On Linux / macOS the Windows entry is omitted and only PKCS11 options are listed.
*
* <p>The caller registers a {@link #setProceedAction(Consumer)} callback that receives either:
* <ul>
* <li>{@link #SELECTION_WINDOWS_MY} — caller opens {@code KeyStore.getInstance("Windows-MY")}</li>
* <li>An absolute filesystem path to a PKCS11 module — caller spins up {@code SunPKCS11}</li>
* </ul>
*/
public class TokenFrame extends JFrame {
/** Sentinel returned to the proceed callback when the user picks the OS-native Windows store. */
public static final String SELECTION_WINDOWS_MY = "WINDOWS-MY";
private final Map<String, String> presets = new LinkedHashMap<>();
private final JComboBox<String> combo = new JComboBox<>();
private final JTextField customPath = new JTextField(28);
private final JButton browseButton = new JButton("Browse...");
private final JButton proceedButton = new JButton("Proceed");
private final JButton cancelButton = new JButton("Cancel");
private final JPanel customPanel;
private Consumer<String> proceedAction = sel -> {};
public TokenFrame() {
super("Select Digital Signature Token");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
loadPresets();
for (final String name : presets.keySet()) {
combo.addItem(name);
}
combo.addItem("Other (browse for library file)");
// Layout
final JPanel main = new JPanel(new BorderLayout(10, 10));
main.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
final JPanel chooser = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
chooser.add(new JLabel("Token / Keystore:"));
chooser.add(combo);
main.add(chooser, BorderLayout.NORTH);
customPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
customPanel.add(new JLabel("Library:"));
customPanel.add(customPath);
customPanel.add(browseButton);
customPanel.setVisible(false);
main.add(customPanel, BorderLayout.CENTER);
final JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 8));
actions.add(cancelButton);
actions.add(proceedButton);
main.add(actions, BorderLayout.SOUTH);
add(main);
// Hooks
combo.addActionListener(e -> {
final boolean isOther = combo.getSelectedItem() != null
&& combo.getSelectedItem().toString().startsWith("Other");
customPanel.setVisible(isOther);
pack();
});
browseButton.addActionListener(e -> {
final JFileChooser fc = new JFileChooser();
if (fc.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) {
customPath.setText(fc.getSelectedFile().getAbsolutePath());
}
});
cancelButton.addActionListener(e -> System.exit(0));
proceedButton.addActionListener(e -> {
final String selection = resolveSelection();
if (selection == null) {
JOptionPane.showMessageDialog(this,
"Please pick a token / keystore option (or browse to a library file).",
"No Selection", JOptionPane.WARNING_MESSAGE);
return;
}
proceedAction.accept(selection);
});
setMinimumSize(new Dimension(560, 180));
pack();
setLocationRelativeTo(null);
}
public void setProceedAction(final Consumer<String> action) {
this.proceedAction = action != null ? action : sel -> {};
}
/** Translates the UI state into the value handed to the proceed callback. */
private String resolveSelection() {
final Object item = combo.getSelectedItem();
if (item == null) {
return null;
}
if (item.toString().startsWith("Other")) {
final String path = customPath.getText();
return (path != null && !path.trim().isEmpty()) ? path.trim() : null;
}
return presets.get(item.toString());
}
/**
* Default token / keystore presets. Adjust paths per install if your customers run different
* driver locations — most CCA-licensed CA tokens stick to the defaults below.
*/
private void loadPresets() {
final String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (os.contains("win")) {
presets.put("Windows Certificate Store (default)", SELECTION_WINDOWS_MY);
presets.put("ePass2003 (eMudhra / Capricorn)", "C:\\Windows\\System32\\eps2003csp11.dll");
presets.put("ePass2003 v2", "C:\\Windows\\System32\\eps2003csp11v2.dll");
presets.put("SafeNet eToken 5110", "C:\\Windows\\System32\\eTPKCS11.dll");
presets.put("WatchData ProxKey", "C:\\Windows\\System32\\WDPKCS.dll");
presets.put("Aladdin eToken", "C:\\Windows\\System32\\eTPkcs11.dll");
presets.put("HYP2003 / Trust Key", "C:\\Windows\\System32\\SignatureP11.dll");
presets.put("mToken CryptoID", "C:\\Windows\\System32\\mToken.dll");
} else if (os.contains("mac") || os.contains("darwin")) {
presets.put("ePass2003 (eMudhra / Capricorn)", "/usr/local/lib/libcastle_v2.dylib");
presets.put("SafeNet eToken", "/usr/local/lib/libeTPkcs11.dylib");
presets.put("WatchData ProxKey", "/usr/local/lib/libwdpkcs.dylib");
} else {
// Linux
presets.put("ePass2003 (eMudhra / Capricorn)", "/usr/lib/x86_64-linux-gnu/libcastle_v2.so");
presets.put("ePass2003 alt path", "/usr/lib/libcastle_v2.so");
presets.put("SafeNet eToken", "/usr/lib/libeToken.so");
presets.put("SafeNet eToken (Ubuntu)", "/usr/lib/x86_64-linux-gnu/libeToken.so");
presets.put("WatchData ProxKey", "/usr/lib/libwdpkcs.so");
presets.put("OpenSC (generic)", "/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so");
}
// Drop entries whose underlying file doesn't exist — keeps the picker honest.
presets.values().removeIf(path -> !SELECTION_WINDOWS_MY.equals(path) && !new File(path).exists());
// Always keep at least Windows-MY on Windows even if the rest were stripped.
if (os.contains("win") && presets.isEmpty()) {
presets.put("Windows Certificate Store (default)", SELECTION_WINDOWS_MY);
}
}
}