Skip to content

Commit 1195bf9

Browse files
authored
Merge pull request #82 from BenCodez/codex/reduce-bc-jar-payload
Reduce full JAR size by removing unused Bouncy Castle versioned payload
2 parents 32e592d + 0f6fb90 commit 1195bf9

4 files changed

Lines changed: 324 additions & 1 deletion

File tree

SimpleAPI/pom.xml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@
151151
<exclude>META-INF/*.RSA</exclude>
152152
</excludes>
153153
</filter>
154+
<!-- The full artifact is not a multi-release JAR. Keep every
155+
base BC class (including reflective provider mappings), but
156+
do not ship versioned implementations it cannot select. -->
157+
<filter>
158+
<artifact>org.bouncycastle:*</artifact>
159+
<excludes>
160+
<exclude>META-INF/versions/**</exclude>
161+
</excludes>
162+
</filter>
154163
</filters>
155164
<relocations>
156165
<relocation>
@@ -182,7 +191,22 @@
182191
<execution>
183192
<id>default-test</id>
184193
<configuration>
185-
<excludes><exclude>**/SharedArtifactTest.java</exclude></excludes>
194+
<excludes>
195+
<exclude>**/SharedArtifactTest.java</exclude>
196+
<exclude>**/FullArtifactTest.java</exclude>
197+
</excludes>
198+
</configuration>
199+
</execution>
200+
<execution>
201+
<id>full-artifact-test</id>
202+
<phase>package</phase>
203+
<goals><goal>test</goal></goals>
204+
<configuration>
205+
<includes><include>**/FullArtifactTest.java</include></includes>
206+
<reportsDirectory>${project.build.directory}/full-artifact-reports</reportsDirectory>
207+
<systemPropertyVariables>
208+
<simpleapi.fullJar>${project.build.directory}/${project.build.finalName}.jar</simpleapi.fullJar>
209+
</systemPropertyVariables>
186210
</configuration>
187211
</execution>
188212
<execution>
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package com.bencodez.simpleapi.servercomm.http;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.io.IOException;
5+
import java.net.InetSocketAddress;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.security.KeyStore;
9+
import java.security.Security;
10+
import java.security.cert.X509Certificate;
11+
import java.time.Clock;
12+
import java.time.Duration;
13+
import java.util.Arrays;
14+
import java.util.concurrent.Executors;
15+
import java.util.concurrent.TimeUnit;
16+
17+
import javax.net.ssl.KeyManager;
18+
import javax.net.ssl.KeyManagerFactory;
19+
import javax.net.ssl.SSLContext;
20+
import javax.net.ssl.SSLException;
21+
import javax.net.ssl.SSLServerSocket;
22+
import javax.net.ssl.SSLSocket;
23+
import javax.net.ssl.TrustManagerFactory;
24+
25+
/** Invoked in a fresh JVM with only the shaded JAR and this fixture on its classpath. */
26+
public final class PackagedTlsSmoke {
27+
private PackagedTlsSmoke() { }
28+
29+
public static void main(String[] args) throws Exception {
30+
require(args.length == 2, "Expected state directory and full artifact");
31+
Path state = Path.of(args[0]);
32+
Path full = Path.of(args[1]);
33+
requireFromJar(HttpTlsIdentity.class, full);
34+
HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(state.resolve("trusted"), "127.0.0.1");
35+
require(Security.getProvider("BC") != null, "BC provider was not registered");
36+
requireFromJar(Security.getProvider("BC").getClass(), full);
37+
X509Certificate ca = identity.caCertificate();
38+
X509Certificate server = identity.serverCertificate();
39+
ca.verify(ca.getPublicKey());
40+
server.verify(ca.getPublicKey());
41+
server.checkValidity();
42+
require(ca.getBasicConstraints() >= 0 && server.getBasicConstraints() == -1, "Incorrect CA constraints");
43+
require(server.getExtendedKeyUsage().contains("1.3.6.1.5.5.7.3.1"), "Missing server-auth usage");
44+
45+
var client = identity.issueClientCertificate("backend-one");
46+
client.certificate().verify(ca.getPublicKey());
47+
require(identity.validClientCertificate("backend-one", client.certificate()), "Valid client rejected");
48+
require(!identity.validClientCertificate("backend-two", client.certificate()), "Wrong backend identity accepted");
49+
require(!identity.validClientCertificate("backend-one", server), "Server certificate accepted as a client");
50+
SSLContext serverContext = identity.serverContext();
51+
exchange(serverContext, clientContext(client, ca), true);
52+
exchange(serverContext, clientContext(null, ca), false);
53+
HttpTlsIdentity foreign = HttpTlsIdentity.loadOrCreate(state.resolve("foreign"), "127.0.0.1");
54+
var foreignClient = foreign.issueClientCertificate("backend-one");
55+
require(!identity.validClientCertificate("backend-one", foreignClient.certificate()), "Foreign CA accepted");
56+
exchange(serverContext, clientContext(foreignClient, ca), false);
57+
58+
HttpTlsIdentity reloaded = HttpTlsIdentity.loadOrCreate(state.resolve("trusted"), "127.0.0.1");
59+
require(Arrays.equals(server.getEncoded(), reloaded.serverCertificate().getEncoded()), "Reload replaced the identity");
60+
HttpTlsIdentity renewed = HttpTlsIdentity.loadOrCreate(state.resolve("trusted"), "127.0.0.1",
61+
Clock.offset(Clock.systemUTC(), Duration.ofDays(340)));
62+
renewed.serverCertificate().verify(ca.getPublicKey());
63+
require(renewed.serverCertificate().getNotAfter().after(server.getNotAfter()), "Server renewal failed");
64+
HttpTlsIdentity renewedCa = HttpTlsIdentity.loadOrCreate(state.resolve("trusted"), "127.0.0.1",
65+
Clock.offset(Clock.systemUTC(), Duration.ofDays(3400)));
66+
renewedCa.caCertificate().verify(ca.getPublicKey());
67+
renewedCa.serverCertificate().verify(renewedCa.caCertificate().getPublicKey());
68+
require(renewedCa.caCertificate().getNotAfter().after(ca.getNotAfter()), "CA renewal failed");
69+
System.out.println("Packaged TLS smoke passed: certificate issuance, PKCS12, mutual TLS, rejection, reload and renewal");
70+
}
71+
72+
private static SSLContext clientContext(HttpTlsIdentity.IssuedClientCertificate client, X509Certificate ca)
73+
throws Exception {
74+
KeyManager[] keys = new KeyManager[0];
75+
if (client != null) {
76+
byte[] encoded = client.pkcs12();
77+
char[] password = client.password();
78+
try {
79+
KeyStore store = KeyStore.getInstance("PKCS12");
80+
try (var input = new ByteArrayInputStream(encoded)) { store.load(input, password); }
81+
KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
82+
factory.init(store, password);
83+
keys = factory.getKeyManagers();
84+
} finally {
85+
Arrays.fill(encoded, (byte) 0);
86+
Arrays.fill(password, '\0');
87+
}
88+
}
89+
KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType());
90+
trust.load(null, new char[0]);
91+
trust.setCertificateEntry("ca", ca);
92+
TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
93+
factory.init(trust);
94+
SSLContext context = SSLContext.getInstance("TLS");
95+
context.init(keys, factory.getTrustManagers(), null);
96+
return context;
97+
}
98+
99+
private static void exchange(SSLContext serverContext, SSLContext clientContext, boolean expected) throws Exception {
100+
var executor = Executors.newSingleThreadExecutor();
101+
try (SSLServerSocket listener = (SSLServerSocket) serverContext.getServerSocketFactory().createServerSocket()) {
102+
listener.bind(new InetSocketAddress("127.0.0.1", 0));
103+
listener.setSoTimeout(5000);
104+
listener.setNeedClientAuth(true);
105+
var accepted = executor.submit(() -> {
106+
try (SSLSocket socket = (SSLSocket) listener.accept()) {
107+
socket.setSoTimeout(5000);
108+
socket.startHandshake();
109+
require(socket.getInputStream().read() == 41, "Missing authenticated request");
110+
socket.getOutputStream().write(42);
111+
return true;
112+
} catch (SSLException rejected) {
113+
return false;
114+
}
115+
});
116+
boolean clientAccepted = false;
117+
try (SSLSocket socket = (SSLSocket) clientContext.getSocketFactory().createSocket()) {
118+
socket.connect(new InetSocketAddress("127.0.0.1", listener.getLocalPort()), 5000);
119+
socket.setSoTimeout(5000);
120+
var parameters = socket.getSSLParameters();
121+
parameters.setEndpointIdentificationAlgorithm("HTTPS");
122+
socket.setSSLParameters(parameters);
123+
socket.startHandshake();
124+
socket.getOutputStream().write(41);
125+
clientAccepted = socket.getInputStream().read() == 42;
126+
} catch (IOException rejected) {
127+
if (expected) throw rejected;
128+
}
129+
// A negative case must be an actual TLS rejection on the server, not a connect/timeout error.
130+
require(accepted.get(10, TimeUnit.SECONDS) == expected, "Unexpected server handshake result");
131+
require(clientAccepted == expected, "Unexpected client handshake result");
132+
} finally {
133+
executor.shutdownNow();
134+
require(executor.awaitTermination(10, TimeUnit.SECONDS), "TLS fixture worker did not stop");
135+
}
136+
}
137+
138+
private static void requireFromJar(Class<?> type, Path full) throws Exception {
139+
Path source = Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI());
140+
require(Files.isSameFile(source, full), "Loaded outside packaged artifact: " + type.getName());
141+
}
142+
143+
private static void require(boolean condition, String message) {
144+
if (!condition) throw new AssertionError(message);
145+
}
146+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.bencodez.simpleapi.tests.packaging;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import java.io.File;
6+
import java.nio.charset.StandardCharsets;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
import java.util.List;
10+
import java.util.concurrent.TimeUnit;
11+
import java.util.jar.Attributes;
12+
import java.util.jar.JarFile;
13+
14+
import org.bouncycastle.asn1.cms.ContentInfo;
15+
import org.bouncycastle.cert.X509CertificateHolder;
16+
import org.bouncycastle.jce.provider.BouncyCastleProvider;
17+
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.api.io.TempDir;
19+
20+
import com.bencodez.simpleapi.servercomm.http.PackagedTlsSmoke;
21+
22+
/** Package-phase checks: Maven's dependency classpath must not mask missing shaded classes. */
23+
public class FullArtifactTest {
24+
@TempDir Path temporary;
25+
26+
@Test void retainsBaseCryptoClassesWithoutUnusedVersionedPayload() throws Exception {
27+
Path full = fullJar();
28+
long removedEntries = 0;
29+
long removedCompressedBytes = 0;
30+
long retainedEntries = 0;
31+
try (JarFile output = new JarFile(full.toFile())) {
32+
assertNotNull(output.getManifest(), "Full artifact must have a manifest");
33+
assertFalse(Boolean.parseBoolean(output.getManifest().getMainAttributes()
34+
.getValue(Attributes.Name.MULTI_RELEASE)),
35+
"Revisit the BC filter before making the full artifact multi-release");
36+
String classPath = output.getManifest().getMainAttributes().getValue(Attributes.Name.CLASS_PATH);
37+
assertTrue(classPath == null || classPath.isBlank(), "Smoke test must not load external manifest dependencies");
38+
assertFalse(output.stream().anyMatch(entry -> entry.getName().startsWith("META-INF/versions/")
39+
&& entry.getName().contains("/org/bouncycastle/")), "Unused versioned BC payload is still bundled");
40+
41+
// Resolve all three original libraries from Maven, without a pinned version or ~/.m2 path.
42+
for (Class<?> anchor : List.of(BouncyCastleProvider.class, X509CertificateHolder.class, ContentInfo.class)) {
43+
Path source = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
44+
assertFalse(Files.isSameFile(source, full), "Expected Maven's original dependency for comparison");
45+
try (JarFile dependency = new JarFile(source.toFile())) {
46+
for (var entry : dependency.stream().filter(entry -> !entry.isDirectory()).toList()) {
47+
if (entry.getName().startsWith("META-INF/versions/")) {
48+
removedEntries++;
49+
removedCompressedBytes += entry.getCompressedSize();
50+
} else if (entry.getName().startsWith("org/bouncycastle/")) {
51+
assertNotNull(output.getEntry(entry.getName()), "Lost base dependency entry: " + entry.getName());
52+
retainedEntries++;
53+
}
54+
}
55+
}
56+
}
57+
}
58+
assertTrue(retainedEntries > 0, "No base crypto entries were checked");
59+
// This is input ZIP payload, not an invented before/after output-JAR size.
60+
System.out.printf("Full artifact: %,d bytes; retained %,d base BC entries; omitted %,d versioned entries "
61+
+ "(%,d compressed bytes in upstream dependency JARs)%n",
62+
Files.size(full), retainedEntries, removedEntries, removedCompressedBytes);
63+
}
64+
65+
@Test void packagedTlsWorksWithoutMavenDependencies() throws Exception {
66+
Path full = fullJar();
67+
String fixtureName = PackagedTlsSmoke.class.getName();
68+
String fixtureResource = fixtureName.replace('.', '/') + ".class";
69+
Path fixtureRoot = Files.createDirectory(temporary.resolve("fixture"));
70+
Path fixtureClass = fixtureRoot.resolve(fixtureResource);
71+
Files.createDirectories(fixtureClass.getParent());
72+
// Copy only this JDK-only fixture, not target/classes, all test classes, or dependency JARs.
73+
try (var input = PackagedTlsSmoke.class.getResourceAsStream("/" + fixtureResource)) {
74+
assertNotNull(input, "Missing compiled TLS fixture");
75+
Files.copy(input, fixtureClass);
76+
}
77+
String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
78+
Path java = Path.of(System.getProperty("java.home"), "bin", executable);
79+
Path log = temporary.resolve("tls-smoke.log");
80+
Process process = new ProcessBuilder(java.toString(), "-cp", full + File.pathSeparator + fixtureRoot,
81+
fixtureName, temporary.resolve("identities").toString(), full.toString())
82+
.redirectErrorStream(true).redirectOutput(log.toFile()).start();
83+
try {
84+
assertTrue(process.waitFor(60, TimeUnit.SECONDS), "Packaged TLS smoke timed out");
85+
String output;
86+
try (var input = Files.newInputStream(log)) {
87+
output = new String(input.readNBytes(64 * 1024), StandardCharsets.UTF_8);
88+
}
89+
assertEquals(0, process.exitValue(), output);
90+
assertTrue(output.contains("Packaged TLS smoke passed"), output);
91+
System.out.print(output);
92+
} finally {
93+
if (process.isAlive()) {
94+
process.destroyForcibly();
95+
assertTrue(process.waitFor(10, TimeUnit.SECONDS), "TLS smoke process did not terminate");
96+
}
97+
}
98+
}
99+
100+
private static Path fullJar() {
101+
String value = System.getProperty("simpleapi.fullJar");
102+
assertNotNull(value, "Run this test through the Maven package lifecycle");
103+
Path full = Path.of(value).toAbsolutePath().normalize();
104+
assertTrue(Files.isRegularFile(full), "Missing packaged full artifact: " + full);
105+
return full;
106+
}
107+
}

docs/jar-packaging.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Full JAR size and cryptography packaging
2+
3+
`SimpleAPI.jar` remains the full, HTTP-capable artifact. The `shared` and
4+
`shared-sources` classifiers, dependency scopes, and public APIs are unchanged.
5+
There are no additional modules or runtime downloads.
6+
7+
The HTTP transport uses Bouncy Castle for its private CA and certificates.
8+
Do not remove those dependencies, switch them to `provided`, strip provider
9+
mappings, or enable broad `minimizeJar` without packaged-runtime validation.
10+
Providers load some implementation classes reflectively.
11+
12+
The full shaded artifact is not a multi-release JAR. Its BC-specific shade
13+
filter omits `META-INF/versions/**`, which that artifact cannot select, while
14+
retaining all base BC classes and resources. Other dependencies are not filtered
15+
by this rule. This is a conservative reduction of redundant payload, not removal
16+
of the crypto provider or the entire HTTP dependency cost.
17+
18+
Java only selects versioned classes when the final manifest declares
19+
`Multi-Release: true`. If that contract changes, revisit this filter and the
20+
packaged-runtime checks rather than silently enabling the attribute. Downstream
21+
consumers that re-shade this artifact receive the base BC implementation.
22+
23+
## Verification
24+
25+
Run from the repository root with JDK 21 and Maven:
26+
27+
```shell
28+
mvn -B -f SimpleAPI/pom.xml clean package
29+
git diff --check
30+
```
31+
32+
The package phase runs `FullArtifactTest` after shading, followed by the existing
33+
shared-classpath tests. It verifies the non-multi-release manifest, absence of
34+
versioned BC payload, and preservation of every base `org/bouncycastle/` entry
35+
from the three resolved BC libraries. It prints the final JAR size and the
36+
compressed upstream payload omitted. That payload counter is not an exact
37+
before/after distribution size: shading/recompression and ZIP overhead differ.
38+
To measure the exact reduction, compare clean baseline and candidate builds with
39+
the same resolved dependencies and JDK.
40+
41+
A fresh JVM runs a JDK-only fixture using just the packaged JAR and the fixture's
42+
single class file. No Maven dependencies or `target/classes` are on its classpath.
43+
It verifies CA/server/client certificate creation, PKCS12 use, authenticated TLS,
44+
rejection of absent/foreign client credentials, persisted identity reload, and
45+
server/CA renewal. The subprocess and socket operations have bounded timeouts.
46+
Reports are written under `SimpleAPI/target/full-artifact-reports/`.

0 commit comments

Comments
 (0)