diff --git a/design-doc/3.1-engine-dss.md b/design-doc/3.1-engine-dss.md index e4ac339f..3c850b86 100644 --- a/design-doc/3.1-engine-dss.md +++ b/design-doc/3.1-engine-dss.md @@ -381,8 +381,16 @@ Read by the engine through the phase-1 `EngineConfig` view (which already maps # Required for producing LT/LTA. Default false to keep B/T fully offline. engine.dss.online.enabled=false -# Trusted-list (LOTL) support for building the trust anchor set. -engine.dss.trust.useDefaultLotl=false +# EU LOTL support for building the trust anchor set. trust.eu.enabled wires the +# bundled European LOTL and validates its signature against the bundled Official +# Journal (OJ) keystore; the eu.* overrides are optional (effective when enabled). +engine.dss.trust.eu.enabled=false +engine.dss.trust.eu.lotlUrl= +engine.dss.trust.eu.ojUrl= +engine.dss.trust.eu.ojKeystoreFile= +engine.dss.trust.eu.ojKeystorePassword= + +# Generic / advanced trust material. engine.dss.trust.lotlUrls= engine.dss.trust.certFiles= engine.dss.trust.certUrls= @@ -391,6 +399,20 @@ engine.dss.trust.truststoreType= engine.dss.trust.truststorePassword= ``` +EU LOTL keys live under the `trust.eu.*` sub-namespace (the turnkey path that +validates the LOTL signature against the bundled OJ keystore and follows the OJ +pivot chain); `trust.lotlUrls` and the cert/truststore keys stay un-prefixed as +the generic "bring your own trust" path. The bundled OJ keystore +(`engines/dss/src/main/resources/.../eu-oj-keystore.p12`) must be refreshed when +the OJ re-issues the LOTL signing certificates — see the keystore README. + +**LT/LTA preflight (issue #432).** Because LT/LTA only embed revocation data for a +*trusted* chain reachable *online*, a missing trust source or `online.enabled` +makes DSS fail deep with an opaque untrusted-chain alert. `DssLtTrustPreflight` +checks `online.enabled AND (trust.eu.enabled ∨ truststoreFile ∨ certFiles ∨ +certUrls ∨ lotlUrls)` up front: the CLI fails fast with the exact keys to set, and +the GUI offers to enable the prerequisites before signing. + `EngineConfig` currently exposes `getString` / `getBoolean` / `getInt`; list- valued keys (`lotlUrls`, `certFiles`, `certUrls`) are parsed by the engine from a delimiter-separated string, so no `EngineConfig` API change is required. (If a diff --git a/distribution/pom.xml b/distribution/pom.xml index 2f08acdd..b8db8977 100644 --- a/distribution/pom.xml +++ b/distribution/pom.xml @@ -46,6 +46,12 @@ .sh .cmd + + --add-exports=jdk.crypto.cryptoki/sun.security.pkcs11=ALL-UNNAMED --add-exports=jdk.crypto.cryptoki/sun.security.pkcs11.wrapper=ALL-UNNAMED --add-exports=java.base/sun.security.action=ALL-UNNAMED --add-exports=java.base/sun.security.rsa=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED unix windows @@ -240,6 +246,18 @@ ${project.version} + + + org.slf4j + slf4j-jdk14 + 2.0.17 + runtime + + + + eu.europa.ec.joinup.sd-dss + specs-trusted-list-v211 + eu.europa.ec.joinup.sd-dss dss-crl-parser-x509crl @@ -79,17 +86,16 @@ test - + eu.europa.ec.joinup.sd-dss dss-validation - test eu.europa.ec.joinup.sd-dss dss-policy-jaxb - test diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java index d2df1027..47016c20 100644 --- a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java +++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java @@ -322,6 +322,12 @@ public boolean sign(final BasicSignerOptions options, final EngineConfig engineC LOGGER.info(RES.get("console.closeStream")); } finished = true; + } catch (eu.europa.esig.dss.alert.exception.AlertException e) { + // LT/LTA: DSS refused because revocation data could not be collected for the signer chain — it is + // not anchored by the configured trust material (its CA is not in the truststore / cert files / + // LOTL, or an MRA LOTL needs engine.dss.trust.lotlMraSupport=true). Surface that instead of an + // opaque stack trace. + LOGGER.log(Level.SEVERE, RES.get("console.dss.untrustedChain"), e); } catch (Exception e) { LOGGER.log(Level.SEVERE, RES.get("console.exception"), e); } catch (OutOfMemoryError e) { diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssTrustConfigurer.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssTrustConfigurer.java index d94aa573..17185e89 100644 --- a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssTrustConfigurer.java +++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssTrustConfigurer.java @@ -26,10 +26,10 @@ import eu.europa.esig.dss.spi.tsl.TrustedListsCertificateSource; import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier; import eu.europa.esig.dss.spi.x509.CertificateSource; -import eu.europa.esig.dss.spi.x509.CommonCertificateSource; import eu.europa.esig.dss.spi.x509.CommonTrustedCertificateSource; import eu.europa.esig.dss.spi.x509.KeyStoreCertificateSource; import eu.europa.esig.dss.spi.x509.aia.DefaultAIASource; +import eu.europa.esig.dss.tsl.function.OfficialJournalSchemeInformationURI; import eu.europa.esig.dss.tsl.job.TLValidationJob; import eu.europa.esig.dss.tsl.source.LOTLSource; @@ -50,14 +50,51 @@ final class DssTrustConfigurer { static final String KEY_ONLINE_ENABLED = "online.enabled"; - static final String KEY_USE_DEFAULT_LOTL = "trust.useDefaultLotl"; + + // --- EU LOTL machinery (trust.eu.* sub-namespace) --- + /** Enable the bundled European LOTL (with the bundled OJ keystore). */ + static final String KEY_EU_ENABLED = "trust.eu.enabled"; + /** Override the default EU LOTL URL (only effective when {@link #KEY_EU_ENABLED} is set). */ + static final String KEY_EU_LOTL_URL = "trust.eu.lotlUrl"; + /** Override the Official Journal scheme-information URL used by the announcement predicate. */ + static final String KEY_EU_OJ_URL = "trust.eu.ojUrl"; + /** Override the bundled OJ keystore with an external file. */ + static final String KEY_EU_OJ_KEYSTORE_FILE = "trust.eu.ojKeystoreFile"; + /** Password for the OJ keystore override file. */ + static final String KEY_EU_OJ_KEYSTORE_PASSWORD = "trust.eu.ojKeystorePassword"; + + // --- generic / advanced trust material --- static final String KEY_LOTL_URLS = "trust.lotlUrls"; + /** + * Enable Mutual Recognition Agreement processing for the {@link #KEY_LOTL_URLS} sources, so DSS grants + * trust to third-country trust services recognised via an MRA LOTL (e.g. the eIDAS international pilot's + * {@code mra_lotl.xml}). Off by default; only MRA LOTLs need it. + */ + static final String KEY_LOTL_MRA_SUPPORT = "trust.lotlMraSupport"; static final String KEY_CERT_FILES = "trust.certFiles"; static final String KEY_CERT_URLS = "trust.certUrls"; static final String KEY_TRUSTSTORE_FILE = "trust.truststoreFile"; static final String KEY_TRUSTSTORE_TYPE = "trust.truststoreType"; static final String KEY_TRUSTSTORE_PASSWORD = "trust.truststorePassword"; + /** Canonical EU List of Trusted Lists location. */ + static final String DEFAULT_EU_LOTL_URL = "https://ec.europa.eu/tools/lotl/eu-lotl.xml"; + + /** + * Default Official Journal scheme-information URL announcing the certificates allowed to sign the EU LOTL. + * Must stay in sync with the bundled OJ keystore ({@link #OJ_KEYSTORE_RESOURCE}); both were rotated by + * OJ C/2026/1944 (April 2026). Override via {@link #KEY_EU_OJ_URL} when pointing at a newer OJ notice. + */ + static final String DEFAULT_OJ_URL = + "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=OJ:C_202601944"; + + /** Classpath location of the bundled OJ keystore (validates the EU LOTL's own signature). */ + static final String OJ_KEYSTORE_RESOURCE = "/net/sf/jsignpdf/engine/dss/eu-oj-keystore.p12"; + + /** Keystore type / password of the bundled OJ keystore (matches the DSS demonstrations keystore). */ + private static final String OJ_KEYSTORE_TYPE = "PKCS12"; + private static final String OJ_KEYSTORE_PASSWORD = "dss-password"; + /** Separator for the list-valued keys (lotlUrls / certFiles / certUrls). */ private static final String LIST_SEPARATOR = "[,;]+"; @@ -125,7 +162,15 @@ private CertificateSource[] createTrustedCertSources() throws Exception { tlValidationJob.setListOfTrustedListSources(lotlSources); TrustedListsCertificateSource trustedListsCertificateSource = new TrustedListsCertificateSource(); tlValidationJob.setTrustedListCertificateSource(trustedListsCertificateSource); - tlValidationJob.onlineRefresh(); + try { + tlValidationJob.onlineRefresh(); + } catch (Exception e) { + // Surface an actionable cause instead of an opaque DSS stack trace; the caller logs this via + // console.dss.trustConfigFailed and aborts signing. Common causes: offline / proxy not + // configured, or a stale OJ keystore that can no longer validate the LOTL signature. + throw new IllegalStateException("Failed to refresh the EU LOTL / trusted lists (check network /" + + " proxy, or update the OJ keystore via " + KEY_EU_OJ_KEYSTORE_FILE + ")", e); + } trustedSources.add(trustedListsCertificateSource); } @@ -173,20 +218,71 @@ private static File tlCacheDirectory() { return cacheDir; } - private LOTLSource[] getLotlSources() { + LOTLSource[] getLotlSources() throws Exception { List lotlSources = new ArrayList<>(); - if (config.getBoolean(KEY_USE_DEFAULT_LOTL, false)) { - lotlSources.add(new LOTLSource()); + CertificateSource ojCertificateSource = null; + + if (config.getBoolean(KEY_EU_ENABLED, false)) { + ojCertificateSource = ojKeystoreCertificateSource(); + lotlSources.add(europeanLotlSource(ojCertificateSource)); + } + + List customLotlUrls = splitList(config.getString(KEY_LOTL_URLS)); + if (!customLotlUrls.isEmpty() && ojCertificateSource == null) { + ojCertificateSource = ojKeystoreCertificateSource(); } - for (String url : splitList(config.getString(KEY_LOTL_URLS))) { + boolean mraSupport = config.getBoolean(KEY_LOTL_MRA_SUPPORT, false); + for (String url : customLotlUrls) { + // Advanced / "bring your own trust": signed by the (bundled or overridden) OJ certs, pivot + // support on, but no OJ announcement predicate (a custom LOTL may not announce the EU OJ URL). + // MRA support is opt-in for third-country mutual-recognition LOTLs. LOTLSource lotlSource = new LOTLSource(); lotlSource.setUrl(url); - lotlSource.setCertificateSource(new CommonCertificateSource()); + lotlSource.setCertificateSource(ojCertificateSource); + lotlSource.setPivotSupport(true); + lotlSource.setMraSupport(mraSupport); lotlSources.add(lotlSource); } return lotlSources.toArray(new LOTLSource[0]); } + /** + * Builds the European LOTL source wired so DSS can validate the LOTL's own signature against the OJ + * keystore and follow the pivot chain to the current trust anchors. The URL and OJ scheme-information URL + * default to the canonical EU values and are overridable via {@link #KEY_EU_LOTL_URL} / {@link #KEY_EU_OJ_URL}. + */ + private LOTLSource europeanLotlSource(CertificateSource ojCertificateSource) { + LOTLSource lotl = new LOTLSource(); + lotl.setUrl(config.getString(KEY_EU_LOTL_URL, DEFAULT_EU_LOTL_URL)); + lotl.setCertificateSource(ojCertificateSource); + lotl.setSigningCertificatesAnnouncementPredicate( + new OfficialJournalSchemeInformationURI(config.getString(KEY_EU_OJ_URL, DEFAULT_OJ_URL))); + lotl.setPivotSupport(true); + return lotl; + } + + /** + * Loads the certificate source that validates the LOTL signature: an external keystore when + * {@link #KEY_EU_OJ_KEYSTORE_FILE} is set, otherwise the keystore bundled on the classpath. Unlike + * {@link #KEY_TRUSTSTORE_FILE} (trust anchors for the document signer), these certs are consumed only by + * the {@link TLValidationJob} to decide whether to accept the LOTL. + */ + private CertificateSource ojKeystoreCertificateSource() throws Exception { + final String overrideFile = config.getString(KEY_EU_OJ_KEYSTORE_FILE); + if (StringUtils.isNotEmpty(overrideFile)) { + final String pwd = config.getString(KEY_EU_OJ_KEYSTORE_PASSWORD, ""); + return new KeyStoreCertificateSource(new File(overrideFile), KeyStore.getDefaultType(), + pwd != null ? pwd.toCharArray() : null); + } + try (InputStream is = DssTrustConfigurer.class.getResourceAsStream(OJ_KEYSTORE_RESOURCE)) { + if (is == null) { + throw new IllegalStateException("Bundled OJ keystore resource not found on the classpath: " + + OJ_KEYSTORE_RESOURCE); + } + return new KeyStoreCertificateSource(is, OJ_KEYSTORE_TYPE, OJ_KEYSTORE_PASSWORD.toCharArray()); + } + } + private static List splitList(String value) { List out = new ArrayList<>(); if (StringUtils.isNotBlank(value)) { diff --git a/engines/dss/src/main/resources/net/sf/jsignpdf/engine/dss/README-oj-keystore.md b/engines/dss/src/main/resources/net/sf/jsignpdf/engine/dss/README-oj-keystore.md new file mode 100644 index 00000000..dfbbcc5f --- /dev/null +++ b/engines/dss/src/main/resources/net/sf/jsignpdf/engine/dss/README-oj-keystore.md @@ -0,0 +1,27 @@ +# EU LOTL Official Journal (OJ) keystore + +`eu-oj-keystore.p12` validates the **signature of the EU List of Trusted Lists (LOTL)** itself +(`LOTLSource.setCertificateSource(...)`). It is *not* a trust anchor for document signers — that is +`engine.dss.trust.truststoreFile`. + +- **Type:** PKCS12 **Password:** `dss-password` (matches the DSS demonstrations keystore convention) +- **Loaded by:** `DssTrustConfigurer.ojKeystoreCertificateSource()` (constant `OJ_KEYSTORE_RESOURCE`) +- **Override at runtime:** `engine.dss.trust.eu.ojKeystoreFile` / `engine.dss.trust.eu.ojKeystorePassword` + +## Current file + +Sourced from the `dss-demonstrations` repository, tag `6.4+20260415` (post the April-2026 OJ rotation). It +holds the EUROPEAN COMMISSION qualified signer certificates (organization + named statutory staff) that sign +the EU LOTL — all `trustedCertEntry`, no private keys. DSS follows the OJ pivot chain from these anchors, so +expired/legacy entries may legitimately remain. + +## Refreshing (release checklist) + +Refresh when the OJ re-issues the LOTL signing certificates (roughly every few years; pivot support absorbs +the more frequent changes). Helper scripts live in `engines/dss/src/main/scripts/`: + +1. Run `fetch-oj-keystore-from-dss-demos.sh ` (recommended) to copy the keystore from a matching + `dss-demonstrations` tag, or `build-oj-keystore-from-live-lotl.sh` to bootstrap from the live LOTL signer. +2. Verify the contained signer certificates (subjects / validity) against the OJ notice (the script prints them). +3. Keep the path / type PKCS12 / password `dss-password`, or update the `OJ_KEYSTORE_*` constants. +4. Update `DssTrustConfigurer.DEFAULT_OJ_URL` to the matching OJ notice URL. diff --git a/engines/dss/src/main/resources/net/sf/jsignpdf/engine/dss/eu-oj-keystore.p12 b/engines/dss/src/main/resources/net/sf/jsignpdf/engine/dss/eu-oj-keystore.p12 new file mode 100644 index 00000000..82c673a6 Binary files /dev/null and b/engines/dss/src/main/resources/net/sf/jsignpdf/engine/dss/eu-oj-keystore.p12 differ diff --git a/engines/dss/src/main/scripts/build-oj-keystore-from-live-lotl.sh b/engines/dss/src/main/scripts/build-oj-keystore-from-live-lotl.sh new file mode 100755 index 00000000..718fa163 --- /dev/null +++ b/engines/dss/src/main/scripts/build-oj-keystore-from-live-lotl.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# bootstrap the OJ keystore from the certificate(s) embedded in the live EU LOTL's +# XML signature. TOFU (trust on first use) — cross-check the printed certs against the OJ notice +# before committing. Produces a PKCS12 with trustedCertEntry entries, password dss-password. +set -euo pipefail + +LOTL_URL="${1:-https://ec.europa.eu/tools/lotl/eu-lotl.xml}" +PASS="dss-password" +DEST="engines/dss/src/main/resources/net/sf/jsignpdf/engine/dss/eu-oj-keystore.p12" + +[ -f pom.xml ] || { echo "Run from the jsignpdf repo root." >&2; exit 1; } +command -v python3 >/dev/null || { echo "python3 required." >&2; exit 1; } + +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +echo ">> Downloading $LOTL_URL ..." +curl -fsSL "$LOTL_URL" -o "$tmp/eu-lotl.xml" + +echo ">> Extracting X509 certificate(s) from the LOTL signature ..." +python3 - "$tmp/eu-lotl.xml" "$tmp" <<'PY' +import re, base64, sys, os +xml = open(sys.argv[1], encoding="utf-8").read() +outdir = sys.argv[2] +b64s = re.findall(r'<(?:\w+:)?X509Certificate>\s*([A-Za-z0-9+/=\s]+?)\s*', xml) +if not b64s: + print("No X509Certificate found in the LOTL signature.", file=sys.stderr); sys.exit(1) +seen = set(); n = 0 +for b in b64s: + der = base64.b64decode("".join(b.split())) + if der in seen: # de-dup + continue + seen.add(der) + pem = "-----BEGIN CERTIFICATE-----\n" + "\n".join( + base64.b64encode(der).decode()[i:i+64] for i in range(0, len(base64.b64encode(der)), 64) + ) + "\n-----END CERTIFICATE-----\n" + open(os.path.join(outdir, f"oj-{n}.pem"), "w").write(pem) + n += 1 +print(n) +PY + +rm -f "$DEST" +i=0 +for pem in "$tmp"/oj-*.pem; do + keytool -importcert -noprompt -trustcacerts -alias "oj-signer-$i" \ + -file "$pem" -keystore "$DEST" -storetype PKCS12 -storepass "$PASS" + i=$((i+1)) +done + +echo ">> Built $DEST with $i certificate(s):" +keytool -list -v -keystore "$DEST" -storetype PKCS12 -storepass "$PASS" \ + | grep -E "Alias name:|Owner:|Valid from:" || true +echo ">> CROSS-CHECK the owners/validity above against the OJ notice before committing." diff --git a/engines/dss/src/main/scripts/fetch-oj-keystore-from-dss-demos.sh b/engines/dss/src/main/scripts/fetch-oj-keystore-from-dss-demos.sh new file mode 100755 index 00000000..d4e4360b --- /dev/null +++ b/engines/dss/src/main/scripts/fetch-oj-keystore-from-dss-demos.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# copy the OJ/LOTL trust keystore from the dss-demonstrations repo, +# matching our DSS version, into the engine resources. Password convention: dss-password. +set -euo pipefail + +TAG="${1:-6.4}" # dss-demonstrations tag == DSS version +PASS="dss-password" +DEST="engines/dss/src/main/resources/net/sf/jsignpdf/engine/dss/eu-oj-keystore.p12" +REPO="https://github.com/esig/dss-demonstrations.git" + +[ -f pom.xml ] || { echo "Run from the jsignpdf repo root." >&2; exit 1; } + +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +echo ">> Cloning $REPO @ $TAG ..." +git clone --depth 1 --branch "$TAG" "$REPO" "$tmp" 2>/dev/null \ + || { echo ">> Tag $TAG not found, falling back to default branch."; git clone --depth 1 "$REPO" "$tmp"; } + +# Find candidate keystores that (a) open with dss-password and (b) contain EU trusted-list signer certs. +echo ">> Searching for the OJ/LOTL keystore ..." +candidate="" +while IFS= read -r ks; do + if keytool -list -keystore "$ks" -storetype PKCS12 -storepass "$PASS" >/dev/null 2>&1; then + if keytool -list -v -keystore "$ks" -storetype PKCS12 -storepass "$PASS" 2>/dev/null \ + | grep -qiE "European Commission|Trusted List|C=EU|LOTL"; then + candidate="$ks"; break + fi + [ -z "$candidate" ] && candidate="$ks" # remember a dss-password p12 even if the heuristic misses + fi +done < <(find "$tmp" -name '*.p12' | sort) + +[ -n "$candidate" ] || { echo "No dss-password PKCS12 keystore found in the repo." >&2; exit 1; } + +echo ">> Using: ${candidate#$tmp/}" +cp "$candidate" "$DEST" +echo ">> Copied to $DEST" +echo ">> Contents:" +keytool -list -v -keystore "$DEST" -storetype PKCS12 -storepass "$PASS" \ + | grep -E "Alias name:|Owner:|Valid from:" || true +echo ">> Done. Review the certificate owners/validity above before committing." diff --git a/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssTrustConfigurerTest.java b/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssTrustConfigurerTest.java new file mode 100644 index 00000000..dbfe9e15 --- /dev/null +++ b/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssTrustConfigurerTest.java @@ -0,0 +1,138 @@ +package net.sf.jsignpdf.engine.dss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import net.sf.jsignpdf.engine.EngineConfig; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Test; + +import eu.europa.esig.dss.tsl.function.OfficialJournalSchemeInformationURI; +import eu.europa.esig.dss.tsl.source.LOTLSource; + +/** + * Offline wiring tests for {@link DssTrustConfigurer#getLotlSources()}. They assert the LOTL sources are + * built with a non-null URL, a certificate source, the OJ announcement predicate and pivot support — + * the regression guard for issue #432 (the bare {@code new LOTLSource()} with a null URL that NPE'd) and the + * RC3 fix (the custom {@code lotlUrls} branch left with an empty certificate source). No network is used. + * + * @author Josef Cacek + */ +public class DssTrustConfigurerTest { + + @Test + public void euEnabledBuildsEuropeanLotlSource() throws Exception { + LOTLSource[] sources = lotlSources(Map.of(DssTrustConfigurer.KEY_EU_ENABLED, "true")); + + assertEquals("trust.eu.enabled must yield exactly one LOTL source", 1, sources.length); + LOTLSource lotl = sources[0]; + assertEquals("default EU LOTL URL", DssTrustConfigurer.DEFAULT_EU_LOTL_URL, lotl.getUrl()); + assertNotNull("OJ keystore certificate source must be wired", lotl.getCertificateSource()); + assertTrue("OJ announcement predicate must be the Official Journal one", + lotl.getSigningCertificatesAnnouncementPredicate() instanceof OfficialJournalSchemeInformationURI); + assertTrue("pivot support must be enabled", lotl.isPivotSupport()); + } + + @Test + public void euLotlUrlOverrideIsApplied() throws Exception { + String override = "https://example.test/eu-lotl.xml"; + LOTLSource[] sources = lotlSources(Map.of( + DssTrustConfigurer.KEY_EU_ENABLED, "true", + DssTrustConfigurer.KEY_EU_LOTL_URL, override)); + + assertEquals(1, sources.length); + assertEquals("euLotlUrl must relocate the EU LOTL", override, sources[0].getUrl()); + } + + @Test + public void customLotlUrlsWiredWithOjCertSourceAndPivotNoPredicate() throws Exception { + LOTLSource[] sources = lotlSources(Map.of( + DssTrustConfigurer.KEY_LOTL_URLS, "https://a.test/lotl.xml, https://b.test/lotl.xml")); + + assertEquals("two custom LOTL URLs -> two sources", 2, sources.length); + for (LOTLSource lotl : sources) { + assertTrue("custom LOTL must have a non-blank URL", StringUtils.isNotBlank(lotl.getUrl())); + assertNotNull("custom LOTL must reuse the OJ certificate source", lotl.getCertificateSource()); + assertTrue("custom LOTL must keep pivot support", lotl.isPivotSupport()); + assertNull("custom LOTL must not carry the EU OJ predicate", + lotl.getSigningCertificatesAnnouncementPredicate()); + } + } + + @Test + public void mraSupportIsOffByDefaultAndOptInForCustomLotlUrls() throws Exception { + LOTLSource[] defaultSources = lotlSources(Map.of( + DssTrustConfigurer.KEY_LOTL_URLS, "https://mra.test/mra_lotl.xml")); + assertEquals(1, defaultSources.length); + assertFalse("MRA support must be off by default", defaultSources[0].isMraSupport()); + + LOTLSource[] mraSources = lotlSources(Map.of( + DssTrustConfigurer.KEY_LOTL_URLS, "https://mra.test/mra_lotl.xml", + DssTrustConfigurer.KEY_LOTL_MRA_SUPPORT, "true")); + assertEquals(1, mraSources.length); + assertTrue("MRA support must be enabled when opted in", mraSources[0].isMraSupport()); + } + + @Test + public void blankAndGarbageLotlUrlEntriesAreSkipped() throws Exception { + // Empty / whitespace-only entries between separators must never become a LOTLSource with a null URL. + LOTLSource[] sources = lotlSources(Map.of( + DssTrustConfigurer.KEY_LOTL_URLS, "https://a.test/lotl.xml,, ; https://b.test/lotl.xml ,")); + + assertEquals("only the two real URLs survive", 2, sources.length); + for (LOTLSource lotl : sources) { + assertTrue("no LOTL source may have a blank URL", StringUtils.isNotBlank(lotl.getUrl())); + } + } + + @Test + public void noLotlConfigYieldsNoSources() throws Exception { + assertEquals("no LOTL config -> no sources", 0, lotlSources(Map.of()).length); + } + + private static LOTLSource[] lotlSources(Map cfg) throws Exception { + return new DssTrustConfigurer(new MapEngineConfig(cfg)).getLotlSources(); + } + + /** Minimal in-memory {@link EngineConfig} backed by a map (keys already engine-relative). */ + private static final class MapEngineConfig implements EngineConfig { + private final Map map; + + MapEngineConfig(Map map) { + this.map = new HashMap<>(map); + } + + @Override + public String getString(String key) { + return map.get(key); + } + + @Override + public String getString(String key, String fallback) { + return map.getOrDefault(key, fallback); + } + + @Override + public boolean getBoolean(String key, boolean fallback) { + String v = map.get(key); + return v == null ? fallback : Boolean.parseBoolean(v); + } + + @Override + public int getInt(String key, int fallback) { + String v = map.get(key); + try { + return v == null ? fallback : Integer.parseInt(v); + } catch (NumberFormatException e) { + return fallback; + } + } + } +} diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/SignerLogic.java b/jsignpdf/src/main/java/net/sf/jsignpdf/SignerLogic.java index 7a918db2..62f494db 100644 --- a/jsignpdf/src/main/java/net/sf/jsignpdf/SignerLogic.java +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/SignerLogic.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.logging.Level; +import net.sf.jsignpdf.engine.DssLtTrustPreflight; import net.sf.jsignpdf.engine.EngineConfig; import net.sf.jsignpdf.engine.EngineMismatchValidator; import net.sf.jsignpdf.engine.EngineMismatchValidator.Mismatch; @@ -83,6 +84,22 @@ public boolean signFile() { } final EngineConfig engineConfig = AppConfig.engineConfigFor(engine.id()); + + // Fail fast on an LT/LTA request the engine isn't configured to satisfy (issue #432), before any + // key/PIN access or network round-trip, with the exact keys to set. + final DssLtTrustPreflight.Result preflight = + DssLtTrustPreflight.check(options, engine, engineConfig); + if (preflight.hasIssues()) { + LOGGER.severe(RES.get("console.dss.ltPreflightFailed")); + if (preflight.onlineMissing()) { + LOGGER.severe(RES.get("console.dss.ltPreflight.online")); + } + if (preflight.trustSourceMissing()) { + LOGGER.severe(RES.get("console.dss.ltPreflight.trust")); + } + return false; + } + finished = engine.sign(options, engineConfig); } catch (Exception e) { LOGGER.log(Level.SEVERE, RES.get("console.exception"), e); diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/engine/DssLtTrustPreflight.java b/jsignpdf/src/main/java/net/sf/jsignpdf/engine/DssLtTrustPreflight.java new file mode 100644 index 00000000..57aca4eb --- /dev/null +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/engine/DssLtTrustPreflight.java @@ -0,0 +1,75 @@ +package net.sf.jsignpdf.engine; + +import net.sf.jsignpdf.BasicSignerOptions; +import net.sf.jsignpdf.types.PadesLevel; + +import org.apache.commons.lang3.StringUtils; + +/** + * Config preflight for the PAdES LT / LTA levels: those embed revocation data, which DSS only fetches for a + * trusted signer chain reachable online. Without both, signing fails deep inside DSS with an + * opaque {@code AlertException} ("Revocation data is skipped for untrusted certificate chain") — the + * defect behind issue #432. This validator detects the misconfiguration up front so the CLI can fail fast and + * the GUI can offer to fix it, before any expensive work (PDF load, key/PIN access, TSA round-trip). + * + *

+ * It is a config-level check, not a connectivity test: a configuration that passes can still fail at runtime + * (LOTL unreachable, proxy, stale OJ keystore), which the engine surfaces via {@code console.dss.trustConfigFailed}. + *

+ * + * @author Josef Cacek + */ +public final class DssLtTrustPreflight { + + // Engine-relative keys (resolved under engine..* by EngineConfig); mirror DssTrustConfigurer's keys. + static final String KEY_ONLINE_ENABLED = "online.enabled"; + static final String KEY_EU_ENABLED = "trust.eu.enabled"; + static final String KEY_TRUSTSTORE_FILE = "trust.truststoreFile"; + static final String KEY_CERT_FILES = "trust.certFiles"; + static final String KEY_CERT_URLS = "trust.certUrls"; + static final String KEY_LOTL_URLS = "trust.lotlUrls"; + + /** + * Outcome of the preflight. + * + * @param applicable whether the check applied at all (LT/LTA selected on an LT-capable engine) + * @param onlineMissing LT/LTA needs online fetching but {@code online.enabled} is off + * @param trustSourceMissing LT/LTA needs a trust anchor but none of the trust sources is configured + */ + public record Result(boolean applicable, boolean onlineMissing, boolean trustSourceMissing) { + /** @return {@code true} when the configuration would make LT/LTA signing fail. */ + public boolean hasIssues() { + return applicable && (onlineMissing || trustSourceMissing); + } + } + + private DssLtTrustPreflight() { + } + + /** + * Checks whether the engine is configured to satisfy an LT/LTA request. + * + * @param o the populated signing options + * @param engine the engine that would sign + * @param config the engine-scoped configuration view + * @return the preflight result; {@link Result#hasIssues()} is {@code false} when nothing is wrong or the + * check does not apply (non-LT/LTA level, or an engine that cannot produce LT) + */ + public static Result check(BasicSignerOptions o, SigningEngine engine, EngineConfig config) { + if (o == null || engine == null || config == null) { + return new Result(false, false, false); + } + final PadesLevel level = o.getPadesLevel(); + final boolean ltOrLta = level == PadesLevel.BASELINE_LT || level == PadesLevel.BASELINE_LTA; + if (!ltOrLta || !engine.capabilities().contains(Capability.PADES_BASELINE_LT)) { + return new Result(false, false, false); + } + final boolean online = config.getBoolean(KEY_ONLINE_ENABLED, false); + final boolean trustSource = config.getBoolean(KEY_EU_ENABLED, false) + || StringUtils.isNotBlank(config.getString(KEY_TRUSTSTORE_FILE)) + || StringUtils.isNotBlank(config.getString(KEY_CERT_FILES)) + || StringUtils.isNotBlank(config.getString(KEY_CERT_URLS)) + || StringUtils.isNotBlank(config.getString(KEY_LOTL_URLS)); + return new Result(true, !online, !trustSource); + } +} diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/preferences/PreferencesController.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/preferences/PreferencesController.java index 2ed6e3d2..0c5223ea 100644 --- a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/preferences/PreferencesController.java +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/preferences/PreferencesController.java @@ -95,7 +95,7 @@ public class PreferencesController { @FXML private ComboBox cmbTsaHashAlgorithm; @FXML private CheckBox chkDssOnlineEnabled; - @FXML private CheckBox chkDssUseDefaultLotl; + @FXML private CheckBox chkDssEuEnabled; @FXML private TextField txtDssLotlUrls; @FXML private TextField txtDssCertFiles; @FXML private TextField txtDssCertUrls; @@ -231,7 +231,7 @@ void bind(PreferencesViewModel viewModel) { cmbTsaHashAlgorithm.valueProperty().bindBidirectional(vm.tsaHashAlgorithmProperty()); chkDssOnlineEnabled.selectedProperty().bindBidirectional(vm.dssOnlineEnabledProperty()); - chkDssUseDefaultLotl.selectedProperty().bindBidirectional(vm.dssUseDefaultLotlProperty()); + chkDssEuEnabled.selectedProperty().bindBidirectional(vm.dssEuEnabledProperty()); txtDssLotlUrls.textProperty().bindBidirectional(vm.dssLotlUrlsProperty()); txtDssCertFiles.textProperty().bindBidirectional(vm.dssCertFilesProperty()); txtDssCertUrls.textProperty().bindBidirectional(vm.dssCertUrlsProperty()); diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/preferences/PreferencesViewModel.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/preferences/PreferencesViewModel.java index 16bfbc5f..d00c59d4 100644 --- a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/preferences/PreferencesViewModel.java +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/preferences/PreferencesViewModel.java @@ -49,7 +49,7 @@ public class PreferencesViewModel { // DSS engine (engine.dss.*) — trust material for PAdES LT/LTA private final BooleanProperty dssOnlineEnabled = new SimpleBooleanProperty(false); - private final BooleanProperty dssUseDefaultLotl = new SimpleBooleanProperty(false); + private final BooleanProperty dssEuEnabled = new SimpleBooleanProperty(false); private final StringProperty dssLotlUrls = new SimpleStringProperty(""); private final StringProperty dssCertFiles = new SimpleStringProperty(""); private final StringProperty dssCertUrls = new SimpleStringProperty(""); @@ -77,7 +77,7 @@ public void loadFrom(AdvancedConfig cfg, String pkcs11FileBody) { tsaHashAlgorithm.set(cfg.getNotEmptyProperty("tsa.hashAlgorithm", "SHA-256")); dssOnlineEnabled.set(cfg.getAsBool("engine.dss.online.enabled", false)); - dssUseDefaultLotl.set(cfg.getAsBool("engine.dss.trust.useDefaultLotl", false)); + dssEuEnabled.set(cfg.getAsBool("engine.dss.trust.eu.enabled", false)); dssLotlUrls.set(orEmpty(cfg.getProperty("engine.dss.trust.lotlUrls"))); dssCertFiles.set(orEmpty(cfg.getProperty("engine.dss.trust.certFiles"))); dssCertUrls.set(orEmpty(cfg.getProperty("engine.dss.trust.certUrls"))); @@ -102,7 +102,7 @@ public void writeTo(AdvancedConfig cfg) { cfg.setProperty("tsa.hashAlgorithm", tsaHashAlgorithm.get()); cfg.setProperty("engine.dss.online.enabled", dssOnlineEnabled.get()); - cfg.setProperty("engine.dss.trust.useDefaultLotl", dssUseDefaultLotl.get()); + cfg.setProperty("engine.dss.trust.eu.enabled", dssEuEnabled.get()); writeStringOrRemove(cfg, "engine.dss.trust.lotlUrls", dssLotlUrls.get()); writeStringOrRemove(cfg, "engine.dss.trust.certFiles", dssCertFiles.get()); writeStringOrRemove(cfg, "engine.dss.trust.certUrls", dssCertUrls.get()); @@ -152,7 +152,7 @@ public void applyTsaDefaults(AdvancedConfig defaults) { public void applyDssDefaults(AdvancedConfig defaults) { dssOnlineEnabled.set(parseBool(defaults.getBundledDefault("engine.dss.online.enabled"), false)); - dssUseDefaultLotl.set(parseBool(defaults.getBundledDefault("engine.dss.trust.useDefaultLotl"), false)); + dssEuEnabled.set(parseBool(defaults.getBundledDefault("engine.dss.trust.eu.enabled"), false)); dssLotlUrls.set(orEmpty(defaults.getBundledDefault("engine.dss.trust.lotlUrls"))); dssCertFiles.set(orEmpty(defaults.getBundledDefault("engine.dss.trust.certFiles"))); dssCertUrls.set(orEmpty(defaults.getBundledDefault("engine.dss.trust.certUrls"))); @@ -258,7 +258,7 @@ private static void writeStringOrRemove(AdvancedConfig cfg, String key, String v public IntegerProperty pdfLibOpenpdfOrderProperty() { return pdfLibOpenpdfOrder; } public StringProperty tsaHashAlgorithmProperty() { return tsaHashAlgorithm; } public BooleanProperty dssOnlineEnabledProperty() { return dssOnlineEnabled; } - public BooleanProperty dssUseDefaultLotlProperty() { return dssUseDefaultLotl; } + public BooleanProperty dssEuEnabledProperty() { return dssEuEnabled; } public StringProperty dssLotlUrlsProperty() { return dssLotlUrls; } public StringProperty dssCertFilesProperty() { return dssCertFiles; } public StringProperty dssCertUrlsProperty() { return dssCertUrls; } diff --git a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/view/MainWindowController.java b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/view/MainWindowController.java index 3a00bfa2..55458b13 100644 --- a/jsignpdf/src/main/java/net/sf/jsignpdf/fx/view/MainWindowController.java +++ b/jsignpdf/src/main/java/net/sf/jsignpdf/fx/view/MainWindowController.java @@ -52,9 +52,12 @@ import net.sf.jsignpdf.Constants; import net.sf.jsignpdf.PdfExtraInfo; import net.sf.jsignpdf.engine.Capability; +import net.sf.jsignpdf.engine.DssLtTrustPreflight; +import net.sf.jsignpdf.engine.EngineConfig; import net.sf.jsignpdf.engine.EngineRegistry; import net.sf.jsignpdf.engine.SigningEngine; import net.sf.jsignpdf.fx.EngineCapabilities; +import net.sf.jsignpdf.utils.AdvancedConfig; import net.sf.jsignpdf.utils.AppConfig; import javafx.scene.layout.VBox; import javafx.util.StringConverter; @@ -1085,6 +1088,12 @@ private void onSign() { options.setOutFile(nameBase + AppConfig.defaultOutSuffix() + suffix); } + // LT/LTA preflight (issue #432): warn before signing if the DSS engine isn't configured for the + // online fetching + trust anchor that LT/LTA require, and offer to enable them. + if (!confirmLtPreflight()) { + return; + } + // Start signing signingService.cancel(); signingService.reset(); @@ -1095,6 +1104,55 @@ private void onSign() { signingService.start(); } + /** + * Runs the LT/LTA trust preflight ({@link DssLtTrustPreflight}) against the active engine and its config. + * When the configuration would make LT/LTA signing fail (issue #432), shows a confirmation offering to + * enable the missing prerequisites ({@code engine.dss.online.enabled} and, if no trust source is set, + * {@code engine.dss.trust.eu.enabled}) and persist them, with a "sign anyway" escape. + * + * @return {@code true} to proceed with signing, {@code false} to abort + */ + private boolean confirmLtPreflight() { + final SigningEngine engine = engineCapabilities.activeEngineProperty().get(); + if (engine == null) { + return true; + } + final EngineConfig engineConfig = AppConfig.engineConfigFor(engine.id()); + final DssLtTrustPreflight.Result preflight = DssLtTrustPreflight.check(options, engine, engineConfig); + if (!preflight.hasIssues()) { + return true; + } + + final Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); + confirm.setTitle(RES.get("jfx.gui.dialog.ltPreflight.title")); + confirm.setHeaderText(null); + confirm.setContentText(RES.get("jfx.gui.dialog.ltPreflight.text")); + confirm.initOwner(stage); + final ButtonType enableAndSign = new ButtonType(RES.get("jfx.gui.dialog.ltPreflight.enable"), + ButtonBar.ButtonData.YES); + final ButtonType signAnyway = new ButtonType(RES.get("jfx.gui.dialog.ltPreflight.anyway"), + ButtonBar.ButtonData.NO); + confirm.getButtonTypes().setAll(enableAndSign, signAnyway, ButtonType.CANCEL); + + final Optional result = confirm.showAndWait(); + if (result.isEmpty() || result.get() == ButtonType.CANCEL) { + return false; + } + if (result.get() == enableAndSign) { + final AdvancedConfig cfg = PropertyStoreFactory.getInstance().advancedConfig(); + cfg.setProperty("engine.dss.online.enabled", true); + if (preflight.trustSourceMissing()) { + cfg.setProperty("engine.dss.trust.eu.enabled", true); + } + try { + cfg.save(); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Could not persist DSS LT/LTA prerequisites", e); + } + } + return true; + } + @FXML private void onZoomIn() { documentVM.zoomIn(); diff --git a/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/view/Preferences.fxml b/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/view/Preferences.fxml index 6b0bf87c..81b2b661 100644 --- a/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/view/Preferences.fxml +++ b/jsignpdf/src/main/resources/net/sf/jsignpdf/fx/view/Preferences.fxml @@ -103,7 +103,7 @@