From 0f6fb900e28e17eb2f536dcfe4d24f789cc2c951 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 15:16:37 -0600 Subject: [PATCH] Reduce unused Bouncy Castle multi-release payload and verify packaged TLS --- SimpleAPI/pom.xml | 26 +++- .../servercomm/http/PackagedTlsSmoke.java | 146 ++++++++++++++++++ .../tests/packaging/FullArtifactTest.java | 107 +++++++++++++ docs/jar-packaging.md | 46 ++++++ 4 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/PackagedTlsSmoke.java create mode 100644 SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/packaging/FullArtifactTest.java create mode 100644 docs/jar-packaging.md diff --git a/SimpleAPI/pom.xml b/SimpleAPI/pom.xml index 3b9eae8..cf7c07e 100644 --- a/SimpleAPI/pom.xml +++ b/SimpleAPI/pom.xml @@ -151,6 +151,15 @@ META-INF/*.RSA + + + org.bouncycastle:* + + META-INF/versions/** + + @@ -182,7 +191,22 @@ default-test - **/SharedArtifactTest.java + + **/SharedArtifactTest.java + **/FullArtifactTest.java + + + + + full-artifact-test + package + test + + **/FullArtifactTest.java + ${project.build.directory}/full-artifact-reports + + ${project.build.directory}/${project.build.finalName}.jar + diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/PackagedTlsSmoke.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/PackagedTlsSmoke.java new file mode 100644 index 0000000..4e44cca --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/PackagedTlsSmoke.java @@ -0,0 +1,146 @@ +package com.bencodez.simpleapi.servercomm.http; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManagerFactory; + +/** Invoked in a fresh JVM with only the shaded JAR and this fixture on its classpath. */ +public final class PackagedTlsSmoke { + private PackagedTlsSmoke() { } + + public static void main(String[] args) throws Exception { + require(args.length == 2, "Expected state directory and full artifact"); + Path state = Path.of(args[0]); + Path full = Path.of(args[1]); + requireFromJar(HttpTlsIdentity.class, full); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(state.resolve("trusted"), "127.0.0.1"); + require(Security.getProvider("BC") != null, "BC provider was not registered"); + requireFromJar(Security.getProvider("BC").getClass(), full); + X509Certificate ca = identity.caCertificate(); + X509Certificate server = identity.serverCertificate(); + ca.verify(ca.getPublicKey()); + server.verify(ca.getPublicKey()); + server.checkValidity(); + require(ca.getBasicConstraints() >= 0 && server.getBasicConstraints() == -1, "Incorrect CA constraints"); + require(server.getExtendedKeyUsage().contains("1.3.6.1.5.5.7.3.1"), "Missing server-auth usage"); + + var client = identity.issueClientCertificate("backend-one"); + client.certificate().verify(ca.getPublicKey()); + require(identity.validClientCertificate("backend-one", client.certificate()), "Valid client rejected"); + require(!identity.validClientCertificate("backend-two", client.certificate()), "Wrong backend identity accepted"); + require(!identity.validClientCertificate("backend-one", server), "Server certificate accepted as a client"); + SSLContext serverContext = identity.serverContext(); + exchange(serverContext, clientContext(client, ca), true); + exchange(serverContext, clientContext(null, ca), false); + HttpTlsIdentity foreign = HttpTlsIdentity.loadOrCreate(state.resolve("foreign"), "127.0.0.1"); + var foreignClient = foreign.issueClientCertificate("backend-one"); + require(!identity.validClientCertificate("backend-one", foreignClient.certificate()), "Foreign CA accepted"); + exchange(serverContext, clientContext(foreignClient, ca), false); + + HttpTlsIdentity reloaded = HttpTlsIdentity.loadOrCreate(state.resolve("trusted"), "127.0.0.1"); + require(Arrays.equals(server.getEncoded(), reloaded.serverCertificate().getEncoded()), "Reload replaced the identity"); + HttpTlsIdentity renewed = HttpTlsIdentity.loadOrCreate(state.resolve("trusted"), "127.0.0.1", + Clock.offset(Clock.systemUTC(), Duration.ofDays(340))); + renewed.serverCertificate().verify(ca.getPublicKey()); + require(renewed.serverCertificate().getNotAfter().after(server.getNotAfter()), "Server renewal failed"); + HttpTlsIdentity renewedCa = HttpTlsIdentity.loadOrCreate(state.resolve("trusted"), "127.0.0.1", + Clock.offset(Clock.systemUTC(), Duration.ofDays(3400))); + renewedCa.caCertificate().verify(ca.getPublicKey()); + renewedCa.serverCertificate().verify(renewedCa.caCertificate().getPublicKey()); + require(renewedCa.caCertificate().getNotAfter().after(ca.getNotAfter()), "CA renewal failed"); + System.out.println("Packaged TLS smoke passed: certificate issuance, PKCS12, mutual TLS, rejection, reload and renewal"); + } + + private static SSLContext clientContext(HttpTlsIdentity.IssuedClientCertificate client, X509Certificate ca) + throws Exception { + KeyManager[] keys = new KeyManager[0]; + if (client != null) { + byte[] encoded = client.pkcs12(); + char[] password = client.password(); + try { + KeyStore store = KeyStore.getInstance("PKCS12"); + try (var input = new ByteArrayInputStream(encoded)) { store.load(input, password); } + KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(store, password); + keys = factory.getKeyManagers(); + } finally { + Arrays.fill(encoded, (byte) 0); + Arrays.fill(password, '\0'); + } + } + KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType()); + trust.load(null, new char[0]); + trust.setCertificateEntry("ca", ca); + TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(trust); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(keys, factory.getTrustManagers(), null); + return context; + } + + private static void exchange(SSLContext serverContext, SSLContext clientContext, boolean expected) throws Exception { + var executor = Executors.newSingleThreadExecutor(); + try (SSLServerSocket listener = (SSLServerSocket) serverContext.getServerSocketFactory().createServerSocket()) { + listener.bind(new InetSocketAddress("127.0.0.1", 0)); + listener.setSoTimeout(5000); + listener.setNeedClientAuth(true); + var accepted = executor.submit(() -> { + try (SSLSocket socket = (SSLSocket) listener.accept()) { + socket.setSoTimeout(5000); + socket.startHandshake(); + require(socket.getInputStream().read() == 41, "Missing authenticated request"); + socket.getOutputStream().write(42); + return true; + } catch (SSLException rejected) { + return false; + } + }); + boolean clientAccepted = false; + try (SSLSocket socket = (SSLSocket) clientContext.getSocketFactory().createSocket()) { + socket.connect(new InetSocketAddress("127.0.0.1", listener.getLocalPort()), 5000); + socket.setSoTimeout(5000); + var parameters = socket.getSSLParameters(); + parameters.setEndpointIdentificationAlgorithm("HTTPS"); + socket.setSSLParameters(parameters); + socket.startHandshake(); + socket.getOutputStream().write(41); + clientAccepted = socket.getInputStream().read() == 42; + } catch (IOException rejected) { + if (expected) throw rejected; + } + // A negative case must be an actual TLS rejection on the server, not a connect/timeout error. + require(accepted.get(10, TimeUnit.SECONDS) == expected, "Unexpected server handshake result"); + require(clientAccepted == expected, "Unexpected client handshake result"); + } finally { + executor.shutdownNow(); + require(executor.awaitTermination(10, TimeUnit.SECONDS), "TLS fixture worker did not stop"); + } + } + + private static void requireFromJar(Class type, Path full) throws Exception { + Path source = Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()); + require(Files.isSameFile(source, full), "Loaded outside packaged artifact: " + type.getName()); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/packaging/FullArtifactTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/packaging/FullArtifactTest.java new file mode 100644 index 0000000..845dd4c --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/packaging/FullArtifactTest.java @@ -0,0 +1,107 @@ +package com.bencodez.simpleapi.tests.packaging; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.jar.Attributes; +import java.util.jar.JarFile; + +import org.bouncycastle.asn1.cms.ContentInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.simpleapi.servercomm.http.PackagedTlsSmoke; + +/** Package-phase checks: Maven's dependency classpath must not mask missing shaded classes. */ +public class FullArtifactTest { + @TempDir Path temporary; + + @Test void retainsBaseCryptoClassesWithoutUnusedVersionedPayload() throws Exception { + Path full = fullJar(); + long removedEntries = 0; + long removedCompressedBytes = 0; + long retainedEntries = 0; + try (JarFile output = new JarFile(full.toFile())) { + assertNotNull(output.getManifest(), "Full artifact must have a manifest"); + assertFalse(Boolean.parseBoolean(output.getManifest().getMainAttributes() + .getValue(Attributes.Name.MULTI_RELEASE)), + "Revisit the BC filter before making the full artifact multi-release"); + String classPath = output.getManifest().getMainAttributes().getValue(Attributes.Name.CLASS_PATH); + assertTrue(classPath == null || classPath.isBlank(), "Smoke test must not load external manifest dependencies"); + assertFalse(output.stream().anyMatch(entry -> entry.getName().startsWith("META-INF/versions/") + && entry.getName().contains("/org/bouncycastle/")), "Unused versioned BC payload is still bundled"); + + // Resolve all three original libraries from Maven, without a pinned version or ~/.m2 path. + for (Class anchor : List.of(BouncyCastleProvider.class, X509CertificateHolder.class, ContentInfo.class)) { + Path source = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI()); + assertFalse(Files.isSameFile(source, full), "Expected Maven's original dependency for comparison"); + try (JarFile dependency = new JarFile(source.toFile())) { + for (var entry : dependency.stream().filter(entry -> !entry.isDirectory()).toList()) { + if (entry.getName().startsWith("META-INF/versions/")) { + removedEntries++; + removedCompressedBytes += entry.getCompressedSize(); + } else if (entry.getName().startsWith("org/bouncycastle/")) { + assertNotNull(output.getEntry(entry.getName()), "Lost base dependency entry: " + entry.getName()); + retainedEntries++; + } + } + } + } + } + assertTrue(retainedEntries > 0, "No base crypto entries were checked"); + // This is input ZIP payload, not an invented before/after output-JAR size. + System.out.printf("Full artifact: %,d bytes; retained %,d base BC entries; omitted %,d versioned entries " + + "(%,d compressed bytes in upstream dependency JARs)%n", + Files.size(full), retainedEntries, removedEntries, removedCompressedBytes); + } + + @Test void packagedTlsWorksWithoutMavenDependencies() throws Exception { + Path full = fullJar(); + String fixtureName = PackagedTlsSmoke.class.getName(); + String fixtureResource = fixtureName.replace('.', '/') + ".class"; + Path fixtureRoot = Files.createDirectory(temporary.resolve("fixture")); + Path fixtureClass = fixtureRoot.resolve(fixtureResource); + Files.createDirectories(fixtureClass.getParent()); + // Copy only this JDK-only fixture, not target/classes, all test classes, or dependency JARs. + try (var input = PackagedTlsSmoke.class.getResourceAsStream("/" + fixtureResource)) { + assertNotNull(input, "Missing compiled TLS fixture"); + Files.copy(input, fixtureClass); + } + String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + Path java = Path.of(System.getProperty("java.home"), "bin", executable); + Path log = temporary.resolve("tls-smoke.log"); + Process process = new ProcessBuilder(java.toString(), "-cp", full + File.pathSeparator + fixtureRoot, + fixtureName, temporary.resolve("identities").toString(), full.toString()) + .redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + assertTrue(process.waitFor(60, TimeUnit.SECONDS), "Packaged TLS smoke timed out"); + String output; + try (var input = Files.newInputStream(log)) { + output = new String(input.readNBytes(64 * 1024), StandardCharsets.UTF_8); + } + assertEquals(0, process.exitValue(), output); + assertTrue(output.contains("Packaged TLS smoke passed"), output); + System.out.print(output); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "TLS smoke process did not terminate"); + } + } + } + + private static Path fullJar() { + String value = System.getProperty("simpleapi.fullJar"); + assertNotNull(value, "Run this test through the Maven package lifecycle"); + Path full = Path.of(value).toAbsolutePath().normalize(); + assertTrue(Files.isRegularFile(full), "Missing packaged full artifact: " + full); + return full; + } +} diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md new file mode 100644 index 0000000..2cf1c4e --- /dev/null +++ b/docs/jar-packaging.md @@ -0,0 +1,46 @@ +# Full JAR size and cryptography packaging + +`SimpleAPI.jar` remains the full, HTTP-capable artifact. The `shared` and +`shared-sources` classifiers, dependency scopes, and public APIs are unchanged. +There are no additional modules or runtime downloads. + +The HTTP transport uses Bouncy Castle for its private CA and certificates. +Do not remove those dependencies, switch them to `provided`, strip provider +mappings, or enable broad `minimizeJar` without packaged-runtime validation. +Providers load some implementation classes reflectively. + +The full shaded artifact is not a multi-release JAR. Its BC-specific shade +filter omits `META-INF/versions/**`, which that artifact cannot select, while +retaining all base BC classes and resources. Other dependencies are not filtered +by this rule. This is a conservative reduction of redundant payload, not removal +of the crypto provider or the entire HTTP dependency cost. + +Java only selects versioned classes when the final manifest declares +`Multi-Release: true`. If that contract changes, revisit this filter and the +packaged-runtime checks rather than silently enabling the attribute. Downstream +consumers that re-shade this artifact receive the base BC implementation. + +## Verification + +Run from the repository root with JDK 21 and Maven: + +```shell +mvn -B -f SimpleAPI/pom.xml clean package +git diff --check +``` + +The package phase runs `FullArtifactTest` after shading, followed by the existing +shared-classpath tests. It verifies the non-multi-release manifest, absence of +versioned BC payload, and preservation of every base `org/bouncycastle/` entry +from the three resolved BC libraries. It prints the final JAR size and the +compressed upstream payload omitted. That payload counter is not an exact +before/after distribution size: shading/recompression and ZIP overhead differ. +To measure the exact reduction, compare clean baseline and candidate builds with +the same resolved dependencies and JDK. + +A fresh JVM runs a JDK-only fixture using just the packaged JAR and the fixture's +single class file. No Maven dependencies or `target/classes` are on its classpath. +It verifies CA/server/client certificate creation, PKCS12 use, authenticated TLS, +rejection of absent/foreign client credentials, persisted identity reload, and +server/CA renewal. The subprocess and socket operations have bounded timeouts. +Reports are written under `SimpleAPI/target/full-artifact-reports/`.