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/`.