Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion SimpleAPI/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
<!-- The full artifact is not a multi-release JAR. Keep every
base BC class (including reflective provider mappings), but
do not ship versioned implementations it cannot select. -->
<filter>
<artifact>org.bouncycastle:*</artifact>
<excludes>
<exclude>META-INF/versions/**</exclude>
</excludes>
</filter>
</filters>
<relocations>
<relocation>
Expand Down Expand Up @@ -182,7 +191,22 @@
<execution>
<id>default-test</id>
<configuration>
<excludes><exclude>**/SharedArtifactTest.java</exclude></excludes>
<excludes>
<exclude>**/SharedArtifactTest.java</exclude>
<exclude>**/FullArtifactTest.java</exclude>
</excludes>
</configuration>
</execution>
<execution>
<id>full-artifact-test</id>
<phase>package</phase>
<goals><goal>test</goal></goals>
<configuration>
<includes><include>**/FullArtifactTest.java</include></includes>
<reportsDirectory>${project.build.directory}/full-artifact-reports</reportsDirectory>
<systemPropertyVariables>
<simpleapi.fullJar>${project.build.directory}/${project.build.finalName}.jar</simpleapi.fullJar>
</systemPropertyVariables>
</configuration>
</execution>
<execution>
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
46 changes: 46 additions & 0 deletions docs/jar-packaging.md
Original file line number Diff line number Diff line change
@@ -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/`.
Loading