diff --git a/SimpleAPI/pom.xml b/SimpleAPI/pom.xml
index c02f0ba..f9559f7 100644
--- a/SimpleAPI/pom.xml
+++ b/SimpleAPI/pom.xml
@@ -16,6 +16,7 @@
21
21
21
+ 1.85
src/main/java
@@ -71,6 +72,11 @@
${project.name}
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.4
+
org.apache.maven.plugins
maven-shade-plugin
@@ -155,6 +161,16 @@
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ ${bouncycastle.version}
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ ${bouncycastle.version}
+
org.spigotmc
spigot-api
@@ -407,4 +423,4 @@
-
\ No newline at end of file
+
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/DurableFiles.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/DurableFiles.java
new file mode 100644
index 0000000..3969f64
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/DurableFiles.java
@@ -0,0 +1,65 @@
+package com.bencodez.simpleapi.file;
+
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Locale;
+
+/** Cross-platform helpers for forcing file contents and published directory entries. */
+public final class DurableFiles {
+ private DurableFiles() { }
+
+ public static void forceFile(Path file) throws IOException {
+ try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
+ channel.force(true);
+ }
+ }
+
+ public static void forceDirectory(Path directory) throws IOException {
+ if (directory == null) return;
+ try {
+ try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
+ channel.force(true);
+ }
+ } catch (AccessDeniedException unsupportedDirectoryHandle) {
+ // The Windows NIO provider cannot open directory handles. File contents are
+ // still forced before atomic publication; do not make persistence unusable.
+ if (!isWindowsName(System.getProperty("os.name", ""))) throw unsupportedDirectoryHandle;
+ } catch (UnsupportedOperationException unsupportedDirectoryForce) {
+ // Some providers support atomic moves but expose no directory-force operation.
+ }
+ }
+
+ public static boolean deleteIfExists(Path target) throws IOException {
+ boolean deleted = Files.deleteIfExists(target);
+ if (deleted) forceDirectory(target.toAbsolutePath().normalize().getParent());
+ return deleted;
+ }
+
+ public static void forceMoveDirectories(Path source, Path target) throws IOException {
+ try {
+ Path sourceParent = source.toAbsolutePath().normalize().getParent();
+ Path targetParent = target.toAbsolutePath().normalize().getParent();
+ forceDirectory(targetParent);
+ if (sourceParent != null && !sourceParent.equals(targetParent)) forceDirectory(sourceParent);
+ } catch (IOException failure) {
+ throw new PublishedException(failure);
+ }
+ }
+
+ public static boolean isWindowsName(String name) {
+ return name != null && name.trim().toLowerCase(Locale.ROOT).startsWith("windows");
+ }
+
+ /** Indicates that an atomic rename completed before metadata writeback failed. */
+ @SuppressWarnings("serial")
+ public static final class PublishedException extends IOException {
+ public PublishedException(IOException cause) {
+ super("File was published but its directory metadata could not be forced", cause);
+ }
+ }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpBackendTransportConnector.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpBackendTransportConnector.java
new file mode 100644
index 0000000..1c5fb4d
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpBackendTransportConnector.java
@@ -0,0 +1,560 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpTimeoutException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Flow;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+
+/** Backend-side, persistent HTTP/1.1 long-poll connector. */
+public final class HttpBackendTransportConnector implements AutoCloseable {
+ public static final Duration CLIENT_TIMEOUT = Duration.ofSeconds(35);
+ private static final Duration RENEWAL_SUCCESS_CHECK = Duration.ofHours(6);
+ private static final Duration RENEWAL_FAILURE_RETRY = Duration.ofMinutes(5);
+ static final int CALLBACK_QUEUE_CAPACITY = 128;
+ private volatile HttpClientCredentialStore.HttpClientProfile profile;
+ private final String serverId;
+ private final Consumer onEnvelope;
+ private volatile HttpClient client;
+ private volatile HttpClientCredentialStore.ClientCredential credential;
+ private final Path credentialDirectory;
+ private final HttpInboundDeliveryStore inboundDeliveries;
+ private final HttpInboundDeliveryStore acknowledgementConfirmationStore;
+ private final URI transportEndpoint;
+ private final ThreadPoolExecutor callbackExecutor;
+ private final AtomicBoolean running = new AtomicBoolean();
+ private final CountDownLatch firstResponse = new CountDownLatch(1);
+ private final Object state = new Object();
+ private final Object renewal = new Object();
+ private final LinkedHashMap outgoing = new LinkedHashMap<>();
+ private final Set received = new LinkedHashSet<>(), processing = new LinkedHashSet<>();
+ private final ArrayDeque acknowledgements = new ArrayDeque<>();
+ private final ArrayDeque acknowledgementConfirmations = new ArrayDeque<>();
+ private final String session = UUID.randomUUID().toString();
+ private volatile Thread poller;
+ private long sequence;
+ private volatile long nextRenewalCheckNanos;
+
+ /** In-memory test constructor; production transport must use a directory-backed constructor. */
+ HttpBackendTransportConnector(HttpConnectionCode code, String serverId,
+ HttpClientCredentialStore.ClientCredential credential, Consumer onEnvelope) throws Exception {
+ this(profile(code, serverId), credential, onEnvelope);
+ }
+
+ /** In-memory test constructor; production transport must use a directory-backed constructor. */
+ HttpBackendTransportConnector(HttpClientCredentialStore.EnrolledClient enrolled, Consumer onEnvelope) throws Exception {
+ this(enrolled, onEnvelope, null);
+ }
+
+ /** In-memory test constructor; production transport must use a directory-backed constructor. */
+ HttpBackendTransportConnector(HttpClientCredentialStore.HttpClientProfile profile,
+ HttpClientCredentialStore.ClientCredential credential, Consumer onEnvelope) throws Exception {
+ this(profile, credential, onEnvelope, null);
+ }
+
+ private HttpBackendTransportConnector(HttpClientCredentialStore.EnrolledClient enrolled, Consumer onEnvelope,
+ Path credentialDirectory) throws Exception {
+ this(enrolled == null ? null : enrolled.profile(), enrolled == null ? null : enrolled.credential(), onEnvelope, credentialDirectory);
+ }
+
+ private HttpBackendTransportConnector(HttpClientCredentialStore.HttpClientProfile profile,
+ HttpClientCredentialStore.ClientCredential credential, Consumer onEnvelope, Path credentialDirectory) throws Exception {
+ if (profile == null || credential == null || onEnvelope == null) throw new IllegalArgumentException("HTTP backend transport configuration is invalid");
+ if (!matchesCredential(profile, credential)) throw new IllegalArgumentException("HTTP client certificate does not match transport profile");
+ this.profile = profile; this.serverId = profile.serverId(); this.onEnvelope = onEnvelope;
+ this.credential = credential;
+ this.credentialDirectory = credentialDirectory;
+ inboundDeliveries = credentialDirectory == null ? null : new HttpInboundDeliveryStore(credentialDirectory);
+ acknowledgementConfirmationStore = credentialDirectory == null ? null
+ : HttpInboundDeliveryStore.open(credentialDirectory, "http-transport-ack-confirmations");
+ if (inboundDeliveries != null) for (var entry : inboundDeliveries.snapshot().entrySet()) {
+ if (entry.getValue() == HttpInboundDeliveryStore.State.COMPLETED) {
+ received.add(entry.getKey());
+ queueAck(entry.getKey());
+ }
+ }
+ if (acknowledgementConfirmationStore != null) for (var entry : acknowledgementConfirmationStore.snapshot().entrySet()) {
+ if (entry.getValue() != HttpInboundDeliveryStore.State.COMPLETED)
+ throw new IOException("HTTP acknowledgement confirmation state is invalid");
+ queueAcknowledgementConfirmation(entry.getKey());
+ }
+ client = client(profile, credential);
+ transportEndpoint = profile.endpoint().resolve("v1/transport");
+ // GlobalMessageHandler routes mutate backend vote state and must observe the
+ // wire order. One bounded lane preserves batch ordering without running work on
+ // the long-poll thread; bounded admission below backpressures this poller.
+ callbackExecutor = executor("SimpleAPI-HTTP-callback", 1, CALLBACK_QUEUE_CAPACITY);
+ }
+
+ /** Convenience constructor for the owner-only credential directory produced by {@link #enroll}. */
+ public HttpBackendTransportConnector(HttpConnectionCode code, String serverId, Path credentials,
+ Consumer onEnvelope) throws Exception {
+ this(HttpClientCredentialStore.loadEnrolled(credentials), onEnvelope, credentials);
+ if (code == null || !profile(code, serverId).equals(this.profile)) throw new IllegalArgumentException("HTTP transport profile does not match connection code");
+ }
+
+ /** Starts normal transport using only the persisted certificate and non-secret profile. */
+ public HttpBackendTransportConnector(Path credentials, Consumer onEnvelope) throws Exception {
+ this(HttpClientCredentialStore.loadEnrolled(credentials), onEnvelope, credentials);
+ }
+
+ /** Performs enrollment network I/O; call this from a connector/setup worker, never a platform main thread. */
+ public static HttpClientCredentialStore.ClientCredential enroll(HttpConnectionCode code, String serverId, Path credentials) throws Exception {
+ if (code == null || credentials == null || serverId == null || !serverId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) throw new IllegalArgumentException("Enrollment configuration is invalid");
+ if (!code.serverId().equals(HttpTlsIdentity.canonicalServerId(serverId)))
+ throw new IllegalArgumentException("HTTP connection code belongs to a different backend");
+ code.requireActive(Clock.systemUTC());
+ byte[] payload = ("{\"server\":\"" + serverId + "\",\"token\":\"" + code.enrollmentToken() + "\"}").getBytes(StandardCharsets.UTF_8);
+ HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).followRedirects(HttpClient.Redirect.NEVER)
+ .connectTimeout(Duration.ofSeconds(5)).sslContext(HttpPinnedTls.clientContext(code)).build();
+ HttpRequest request = HttpRequest.newBuilder(code.endpoint().resolve("v1/enroll")).timeout(CLIENT_TIMEOUT)
+ .header("Content-Type", "application/json").header("Cache-Control", "no-store").POST(HttpRequest.BodyPublishers.ofByteArray(payload)).build();
+ LimitedResponse response = sendLimited(client, request);
+ if (response.statusCode() != 201) throw new IllegalArgumentException("Enrollment was rejected");
+ HttpTlsIdentity.IssuedClientCertificate issued = HttpTransportProtocol.parseEnrollmentResponse(serverId, response.body());
+ HttpClientCredentialStore.saveEnrolled(credentials, code, issued); return HttpClientCredentialStore.load(credentials);
+ }
+
+ public void start() {
+ if (!running.compareAndSet(false, true)) return;
+ poller = new Thread(this::pollLoop, "SimpleAPI-HTTP-poll"); poller.setDaemon(true); poller.start();
+ }
+ /** Waits for one authenticated, protocol-valid transport response. */
+ public boolean awaitFirstResponse(long deadlineNanos) throws InterruptedException {
+ long remaining = deadlineNanos - System.nanoTime();
+ return remaining > 0L && firstResponse.await(remaining, TimeUnit.NANOSECONDS) && running.get();
+ }
+ /**
+ * Inserts an in-memory at-least-once delivery. It survives retry/lost responses while this process remains alive;
+ * callers needing restart durability must retain the application operation independently.
+ */
+ public boolean send(JsonEnvelope envelope) {
+ if (envelope == null || !running.get()) return false;
+ try { HttpTransportProtocol.validateEnvelope(envelope); }
+ catch (IllegalArgumentException invalid) { return false; }
+ synchronized (state) {
+ if (outgoing.size() >= HttpTransportProtocol.MAX_QUEUE) return false;
+ String id = UUID.randomUUID().toString(); outgoing.put(id, new HttpTransportProtocol.Delivery(id, envelope)); return true;
+ }
+ }
+ /** A synchronous single poll, useful for lifecycle-controlled integrations and tests. */
+ public synchronized boolean pollOnce() {
+ return pollOnce(CLIENT_TIMEOUT, true, true);
+ }
+ private boolean pollOnce(Duration timeout, boolean requireRunning, boolean acceptIncoming) {
+ if (requireRunning && !running.get()) return false;
+ List acks = List.of(), ackConfirmations = List.of();
+ boolean acknowledgementsConfirmed = false, confirmationsConfirmed = false;
+ try {
+ if (requireRunning) maybeRenewCredential();
+ List messages; long requestSequence;
+ synchronized (state) {
+ acks = first(acknowledgements);
+ ackConfirmations = first(acknowledgementConfirmations);
+ requestSequence = sequence++;
+ messages = HttpTransportProtocol.fittingMessages(serverId, session, requestSequence, acks,
+ ackConfirmations, outgoing.values());
+ for (int index = 0; index < acks.size(); index++) acknowledgements.removeFirst();
+ for (int index = 0; index < ackConfirmations.size(); index++) acknowledgementConfirmations.removeFirst();
+ }
+ HttpRequest request = HttpRequest.newBuilder(transportEndpoint).timeout(timeout).header("Content-Type", "application/json")
+ .header("Cache-Control", "no-store").POST(HttpRequest.BodyPublishers.ofByteArray(HttpTransportProtocol.request(
+ serverId, session, requestSequence, acks, ackConfirmations, messages))).build();
+ LimitedResponse response = sendLimited(client, request);
+ if (response.statusCode() != 200) return false;
+ HttpTransportProtocol.Packet packet = HttpTransportProtocol.parsePacket(response.body());
+ if (!serverId.equals(packet.server()) || !session.equals(packet.session()) || packet.sequence() != requestSequence) return false;
+ if (!acks.equals(packet.ackConfirmations())) return false;
+ confirmAcknowledgements(packet.ackConfirmations());
+ acknowledgementsConfirmed = true;
+ if (!confirmSentAcknowledgementConfirmations(ackConfirmations)) return false;
+ confirmationsConfirmed = true;
+ if (!recordAcknowledgementConfirmations(packet.acks())) return false;
+ synchronized (state) { for (String ack : packet.acks()) {
+ outgoing.remove(ack); queueAcknowledgementConfirmation(ack);
+ } }
+ if (acceptIncoming) for (HttpTransportProtocol.Delivery delivery : accept(packet.messages())) dispatch(delivery);
+ firstResponse.countDown();
+ return true;
+ } catch (Exception failure) { return false;
+ } finally {
+ if (!acknowledgementsConfirmed) requeue(acknowledgements, acks);
+ if (!confirmationsConfirmed) requeue(acknowledgementConfirmations, ackConfirmations);
+ }
+ }
+ /** Stops normal polling and gives already-queued outbound messages a bounded final delivery attempt. */
+ public boolean flushOutgoing(long deadlineNanos) {
+ running.set(false);
+ firstResponse.countDown();
+ Thread current = poller;
+ if (current != null) current.interrupt();
+ if (!joinPoller(current, deadlineNanos)) return false;
+ while (queuedOutgoing() != 0 || queuedAcknowledgements() != 0) {
+ long remaining = deadlineNanos - System.nanoTime();
+ if (remaining <= 0L) return false;
+ Duration timeout = Duration.ofNanos(Math.min(CLIENT_TIMEOUT.toNanos(), remaining));
+ synchronized (this) {
+ if (pollOnce(timeout, false, false)) continue;
+ }
+ // The proxy may retain its one-active-poll guard briefly after the old
+ // client request is interrupted. Retry that transient 409 without busy
+ // spinning, but never extend the caller's shutdown deadline.
+ remaining = deadlineNanos - System.nanoTime();
+ if (remaining <= 0L) return false;
+ try { TimeUnit.NANOSECONDS.sleep(Math.min(TimeUnit.MILLISECONDS.toNanos(50), remaining)); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return false; }
+ }
+ return true;
+ }
+ @Override public void close() {
+ running.getAndSet(false);
+ firstResponse.countDown();
+ // Revoke this connector's journal writer before a replacement snapshots it.
+ // In-flight transitions serialize with seal(): either COMPLETED is already
+ // durable, or the delivery remains durably RUNNING and fail-closed.
+ if (inboundDeliveries != null) inboundDeliveries.seal();
+ if (acknowledgementConfirmationStore != null) acknowledgementConfirmationStore.seal();
+ Thread current = poller; if (current != null) current.interrupt();
+ callbackExecutor.shutdown(); try { if (!callbackExecutor.awaitTermination(5, TimeUnit.SECONDS)) callbackExecutor.shutdownNow(); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); callbackExecutor.shutdownNow(); }
+ // The owning transport may release the credential-directory semaphore as soon
+ // as close returns. Wait for the interrupted poller so an in-flight renewal
+ // cannot activate an old credential generation after that ownership handoff.
+ joinPoller(current);
+ }
+ boolean pollerAlive() { Thread current = poller; return current != null && current.isAlive(); }
+ private static boolean joinPoller(Thread poller, long deadlineNanos) {
+ if (poller == null || poller == Thread.currentThread()) return true;
+ boolean interrupted = false;
+ while (poller.isAlive()) {
+ long remaining = deadlineNanos - System.nanoTime();
+ if (remaining <= 0L) {
+ if (interrupted) Thread.currentThread().interrupt();
+ return false;
+ }
+ try { TimeUnit.NANOSECONDS.timedJoin(poller, remaining); }
+ catch (InterruptedException stopRequested) { interrupted = true; poller.interrupt(); }
+ }
+ if (interrupted) Thread.currentThread().interrupt();
+ return true;
+ }
+ private static void joinPoller(Thread poller) {
+ if (poller == null || poller == Thread.currentThread()) return;
+ boolean interrupted = false;
+ while (poller.isAlive()) try { poller.join(); }
+ catch (InterruptedException stopRequested) { interrupted = true; poller.interrupt(); }
+ if (interrupted) Thread.currentThread().interrupt();
+ }
+
+ private void pollLoop() {
+ long retry = 1000L;
+ while (running.get()) { if (pollOnce()) { retry = 1000L; continue; } try { Thread.sleep(retry); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); break; } retry = Math.min(30_000L, retry * 2); }
+ }
+ List accept(List deliveries) {
+ synchronized (state) {
+ List accepted = new java.util.ArrayList<>();
+ for (HttpTransportProtocol.Delivery delivery : deliveries) {
+ HttpInboundDeliveryStore.State persisted = inboundDeliveries == null ? null : inboundDeliveries.state(delivery.id());
+ if (received.contains(delivery.id()) || persisted == HttpInboundDeliveryStore.State.COMPLETED) {
+ received.add(delivery.id()); queueAck(delivery.id()); continue;
+ }
+ // A callback that was running when the process stopped may already have
+ // produced external side effects. Keep the proxy copy without replaying or
+ // acknowledging it; arbitrary plugin callbacks cannot share this journal.
+ if (persisted == HttpInboundDeliveryStore.State.RUNNING) continue;
+ if (!processing.contains(delivery.id())) {
+ processing.add(delivery.id()); accepted.add(delivery);
+ }
+ }
+ return accepted;
+ }
+ }
+ void dispatch(HttpTransportProtocol.Delivery delivery) {
+ Runnable callback = () -> {
+ boolean success = false;
+ try {
+ if (inboundDeliveries != null) {
+ if (inboundDeliveries.state(delivery.id()) == null) inboundDeliveries.reserve(delivery.id());
+ inboundDeliveries.markRunning(delivery.id());
+ }
+ onEnvelope.accept(delivery.envelope());
+ if (inboundDeliveries != null) inboundDeliveries.markCompleted(delivery.id());
+ success = true;
+ } catch (IOException persistenceFailure) {
+ // Never run before RUNNING is durable and never acknowledge until
+ // COMPLETED is durable. An uncertain transition stays fail-closed.
+ } catch (RuntimeException callbackFailure) {
+ // The callback may have failed after partial external effects. Leave RUNNING
+ // unacknowledged so a restart cannot silently lose or duplicate the delivery.
+ }
+ completeIncoming(delivery.id(), success);
+ };
+ if (!executeOrdered(callbackExecutor, callback)) completeIncoming(delivery.id(), false);
+ }
+ void completeIncoming(String id, boolean success) { synchronized (state) {
+ processing.remove(id);
+ if (success) { received.add(id); while (received.size() > HttpTransportProtocol.MAX_QUEUE) received.remove(received.iterator().next()); queueAck(id); }
+ } }
+ private void queueAck(String id) { if (acknowledgements.size() < HttpTransportProtocol.MAX_QUEUE && !acknowledgements.contains(id)) acknowledgements.add(id); }
+ private void queueAcknowledgementConfirmation(String id) {
+ if (acknowledgementConfirmations.size() < HttpTransportProtocol.MAX_QUEUE
+ && !acknowledgementConfirmations.contains(id)) acknowledgementConfirmations.add(id);
+ }
+ private void requeue(ArrayDeque queue, List ids) { synchronized (state) {
+ for (int index = ids.size() - 1; index >= 0; index--) {
+ String id = ids.get(index);
+ if (!queue.contains(id)) {
+ while (queue.size() >= HttpTransportProtocol.MAX_QUEUE) queue.removeLast();
+ queue.addFirst(id);
+ }
+ }
+ } }
+ private void confirmAcknowledgements(Collection ids) {
+ for (String id : ids) {
+ boolean removed = true;
+ if (inboundDeliveries != null) try { inboundDeliveries.remove(id); }
+ catch (IOException cleanupFailure) { removed = false; }
+ synchronized (state) {
+ if (removed) received.remove(id);
+ else queueAck(id);
+ }
+ }
+ }
+ private boolean recordAcknowledgementConfirmations(Collection ids) {
+ if (acknowledgementConfirmationStore == null) return true;
+ try { for (String id : ids) acknowledgementConfirmationStore.recordCompleted(id); return true; }
+ catch (IOException persistenceFailure) { return false; }
+ }
+ private boolean confirmSentAcknowledgementConfirmations(Collection ids) {
+ if (acknowledgementConfirmationStore == null) return true;
+ try { for (String id : ids) acknowledgementConfirmationStore.remove(id); return true; }
+ catch (IOException cleanupFailure) { return false; }
+ }
+ int queuedOutgoing() { synchronized (state) { return outgoing.size(); } }
+ int queuedAcknowledgements() { synchronized (state) { return acknowledgements.size() + acknowledgementConfirmations.size(); } }
+ List drainAcknowledgements() { synchronized (state) { return drain(acknowledgements); } }
+ private static List first(Collection values) { List output = new java.util.ArrayList<>(); for (T value : values) { output.add(value); if (output.size() == HttpTransportProtocol.MAX_BATCH) break; } return output; }
+ private static List drain(ArrayDeque values) { List output = new java.util.ArrayList<>(); while (!values.isEmpty() && output.size() < HttpTransportProtocol.MAX_BATCH) output.add(values.remove()); return output; }
+ private static ThreadPoolExecutor executor(String name, int threads, int queue) { ThreadFactory factory = task -> { Thread thread = new Thread(task, name); thread.setDaemon(true); return thread; }; return new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queue), factory, new ThreadPoolExecutor.AbortPolicy()); }
+ static boolean executeOrdered(ThreadPoolExecutor executor, Runnable task) {
+ try { executor.execute(task); return true; }
+ catch (RejectedExecutionException fullOrClosed) {
+ if (executor.isShutdown()) return false;
+ try {
+ while (!executor.isShutdown()) {
+ if (!executor.getQueue().offer(task, 100L, TimeUnit.MILLISECONDS)) continue;
+ if (executor.isShutdown() && executor.remove(task)) return false;
+ return true;
+ }
+ } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
+ return false;
+ }
+ }
+ private static HttpClientCredentialStore.HttpClientProfile profile(HttpConnectionCode code, String serverId) {
+ if (code == null || serverId == null) throw new IllegalArgumentException("HTTP backend transport configuration is invalid");
+ if (!code.serverId().equals(HttpTlsIdentity.canonicalServerId(serverId))) throw new IllegalArgumentException("HTTP connection code belongs to a different backend");
+ return new HttpClientCredentialStore.HttpClientProfile(serverId, code.endpoint(), code.serverCertificatePin(), code.caCertificatePin());
+ }
+ private static boolean matchesCredential(HttpClientCredentialStore.HttpClientProfile profile,
+ HttpClientCredentialStore.ClientCredential credential) {
+ try {
+ credential.certificate().checkValidity(); credential.certificate().verify(credential.caCertificate().getPublicKey());
+ String expected = "urn:votingplugin:http-backend:" + profile.serverId();
+ var names = credential.certificate().getSubjectAlternativeNames(); if (names == null) return false;
+ for (java.util.List> name : names) if (name.size() == 2 && Integer.valueOf(6).equals(name.get(0)) && expected.equals(name.get(1))) return true;
+ return false;
+ } catch (Exception invalid) { return false; }
+ }
+ private void maybeRenewCredential() {
+ synchronized (renewal) {
+ Path directory = credentialDirectory;
+ if (directory == null || !HttpTlsIdentity.needsRenewal(credential.certificate(), Clock.systemUTC())) return;
+ long now = System.nanoTime();
+ if (nextRenewalCheckNanos != 0L && now - nextRenewalCheckNanos < 0L) return;
+ Duration retry = renewalRetryDelay(Duration.between(Instant.now(),
+ credential.certificate().getNotAfter().toInstant()));
+ try {
+ byte[] body = HttpTransportProtocol.renewalRequest(serverId);
+ HttpRequest request = HttpRequest.newBuilder(profile.endpoint().resolve("v1/renew")).timeout(CLIENT_TIMEOUT)
+ .header("Content-Type", "application/json").header("Cache-Control", "no-store")
+ .POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
+ LimitedResponse response = sendLimited(client, request);
+ if (response.statusCode() != 201) {
+ nextRenewalCheckNanos = renewalDeadline(now, retry);
+ return;
+ }
+ HttpTlsIdentity.IssuedClientCertificate issued = HttpTransportProtocol.parseEnrollmentResponse(serverId, response.body());
+ HttpClientCredentialStore.StagedCredential staged = HttpClientCredentialStore.stageReplacement(directory, issued);
+ HttpClientCredentialStore.ClientCredential replacement = staged.credential();
+ HttpClientCredentialStore.HttpClientProfile replacementProfile = staged.profile();
+ if (!matchesCredential(replacementProfile, replacement)) throw new IllegalArgumentException("Renewed HTTP certificate is invalid");
+ HttpClient replacementClient = client(replacementProfile, replacement);
+ HttpClientCredentialStore.activateReplacement(directory, staged);
+ profile = replacementProfile;
+ client = replacementClient;
+ credential = replacement;
+ nextRenewalCheckNanos = renewalDeadline(now, RENEWAL_SUCCESS_CHECK);
+ } catch (Exception ignored) {
+ // Keep the active generation and retry well before its remaining validity is consumed.
+ nextRenewalCheckNanos = renewalDeadline(now, retry);
+ }
+ }
+ }
+ static Duration renewalRetryDelay(Duration remainingValidity) {
+ if (remainingValidity == null || remainingValidity.isNegative() || remainingValidity.isZero())
+ return Duration.ofSeconds(1);
+ Duration beforeExpiry = remainingValidity.dividedBy(4L);
+ if (beforeExpiry.isZero()) beforeExpiry = Duration.ofNanos(1L);
+ return beforeExpiry.compareTo(RENEWAL_FAILURE_RETRY) < 0 ? beforeExpiry : RENEWAL_FAILURE_RETRY;
+ }
+ private static long renewalDeadline(long now, Duration delay) {
+ long nanos;
+ try { nanos = delay.toNanos(); }
+ catch (ArithmeticException overflow) { nanos = Long.MAX_VALUE; }
+ return nanos > Long.MAX_VALUE - now ? Long.MAX_VALUE : now + nanos;
+ }
+ private static HttpClient client(HttpClientCredentialStore.HttpClientProfile profile,
+ HttpClientCredentialStore.ClientCredential credential) throws Exception {
+ return HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).followRedirects(HttpClient.Redirect.NEVER)
+ .connectTimeout(Duration.ofSeconds(5)).sslContext(clientContext(profile, credential)).build();
+ }
+ static LimitedResponse sendLimited(HttpClient client, HttpRequest request) throws IOException, InterruptedException {
+ CompletableFuture> exchange = client.sendAsync(request,
+ ignored -> new LimitedBodySubscriber(HttpTransportProtocol.MAX_BODY_BYTES));
+ HttpResponse response;
+ try {
+ Duration timeout = request.timeout().orElse(CLIENT_TIMEOUT);
+ response = exchange.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (TimeoutException timeout) {
+ exchange.cancel(true);
+ throw new HttpTimeoutException("HTTP transport response timed out");
+ } catch (InterruptedException interrupted) {
+ exchange.cancel(true);
+ throw interrupted;
+ } catch (ExecutionException failure) {
+ Throwable cause = failure.getCause();
+ if (cause instanceof IOException ioFailure) throw ioFailure;
+ throw new IOException("HTTP transport request failed", cause);
+ }
+ long declaredLength = response.headers().firstValueAsLong("Content-Length").orElse(-1L);
+ if (declaredLength > HttpTransportProtocol.MAX_BODY_BYTES)
+ throw new IOException("HTTP transport response exceeds its limit");
+ return new LimitedResponse(response.statusCode(), response.body());
+ }
+
+ private static final class LimitedBodySubscriber implements HttpResponse.BodySubscriber {
+ private final int maximum;
+ private final ByteArrayOutputStream body = new ByteArrayOutputStream();
+ private final CompletableFuture result = new CompletableFuture<>();
+ private Flow.Subscription subscription;
+
+ private LimitedBodySubscriber(int maximum) {
+ this.maximum = maximum;
+ }
+
+ @Override
+ public CompletionStage getBody() {
+ return result;
+ }
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ if (this.subscription != null) {
+ subscription.cancel();
+ return;
+ }
+ this.subscription = subscription;
+ subscription.request(1);
+ }
+
+ @Override
+ public void onNext(List buffers) {
+ try {
+ for (ByteBuffer buffer : buffers) {
+ if (buffer.remaining() > maximum - body.size()) {
+ subscription.cancel();
+ result.completeExceptionally(new IOException("HTTP transport response exceeds its limit"));
+ return;
+ }
+ byte[] chunk = new byte[buffer.remaining()];
+ buffer.get(chunk);
+ body.writeBytes(chunk);
+ }
+ subscription.request(1);
+ } catch (RuntimeException failure) {
+ subscription.cancel();
+ result.completeExceptionally(failure);
+ }
+ }
+
+ @Override
+ public void onError(Throwable failure) {
+ result.completeExceptionally(failure);
+ }
+
+ @Override
+ public void onComplete() {
+ result.complete(body.toByteArray());
+ }
+ }
+ static byte[] readLimited(InputStream body) throws IOException {
+ byte[] bytes = body.readNBytes(HttpTransportProtocol.MAX_BODY_BYTES + 1);
+ if (bytes.length > HttpTransportProtocol.MAX_BODY_BYTES)
+ throw new IOException("HTTP transport response exceeds its limit");
+ return bytes;
+ }
+ record LimitedResponse(int statusCode, byte[] body) { }
+ private static SSLContext clientContext(HttpClientCredentialStore.HttpClientProfile profile, HttpClientCredentialStore.ClientCredential credential) throws Exception {
+ String caPin = HttpTransportSecrets.certificatePin(credential.caCertificate());
+ if (!HttpTransportSecrets.constantTimeEquals(profile.caCertificatePin().getBytes(StandardCharsets.US_ASCII),
+ caPin.getBytes(StandardCharsets.US_ASCII))) throw new IllegalArgumentException("HTTP authority does not match transport profile");
+ char[] password = credential.password();
+ try {
+ KeyStore store = KeyStore.getInstance("PKCS12"); store.load(null, new char[0]);
+ store.setKeyEntry("client", credential.privateKey(), password,
+ new java.security.cert.Certificate[] { credential.certificate(), credential.caCertificate() });
+ KeyManagerFactory keys = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); keys.init(store, password);
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); trustStore.load(null, new char[0]);
+ trustStore.setCertificateEntry("http-transport-ca", credential.caCertificate());
+ javax.net.ssl.TrustManagerFactory trusts = javax.net.ssl.TrustManagerFactory.getInstance(
+ javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm());
+ trusts.init(trustStore);
+ SSLContext context = SSLContext.getInstance("TLS"); context.init(keys.getKeyManagers(), trusts.getTrustManagers(), null); return context;
+ } finally { java.util.Arrays.fill(password, '\0'); }
+ }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpClientCredentialStore.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpClientCredentialStore.java
new file mode 100644
index 0000000..f61900e
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpClientCredentialStore.java
@@ -0,0 +1,399 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.net.URI;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.Base64;
+import java.util.EnumSet;
+import java.util.Properties;
+import com.bencodez.simpleapi.file.DurableFiles;
+
+/** Owner-only persistence for the client certificate bundle returned by enrollment. */
+public final class HttpClientCredentialStore {
+ private static final String BUNDLE_FILE = "http-transport-client.p12";
+ private static final String PASSWORD_FILE = "http-transport-client-password";
+ private static final String PROFILE_FILE = "http-transport-profile.properties";
+ private static final String CONNECTION_CODE_DIGEST_FILE = "http-transport-connection-code.sha256";
+ private static final String GENERATIONS_DIRECTORY = "http-transport-client-generations";
+ private static final String CURRENT_FILE = "http-transport-client-current";
+ private HttpClientCredentialStore() { }
+
+ public static void save(Path directory, HttpTlsIdentity.IssuedClientCertificate issued) throws IOException {
+ if (issued == null) throw new IllegalArgumentException("Issued credential is required");
+ directory = credentialRoot(directory, true);
+ byte[] bundle = issued.pkcs12();
+ try { writePrivate(safe(directory.resolve(BUNDLE_FILE)), bundle); }
+ finally { java.util.Arrays.fill(bundle, (byte) 0); }
+ char[] password = issued.password();
+ try { writePrivate(safe(directory.resolve(PASSWORD_FILE)), asciiBytes(password)); }
+ finally { java.util.Arrays.fill(password, '\0'); }
+ }
+
+ /** Persists the certificate plus the non-secret normal-transport profile after enrollment. */
+ public static void saveEnrolled(Path directory, HttpConnectionCode code, HttpTlsIdentity.IssuedClientCertificate issued)
+ throws IOException {
+ if (code == null || issued == null) throw new IllegalArgumentException("Connection code and credential are required");
+ HttpClientProfile profile = new HttpClientProfile(HttpTlsIdentity.canonicalServerId(issued.serverId()), code.endpoint(),
+ code.serverCertificatePin(), code.caCertificatePin());
+ try {
+ StagedCredential staged = stage(directory, issued, profile, connectionCodeDigest(code));
+ activateReplacement(directory, staged);
+ } catch (IOException failure) { throw failure;
+ } catch (Exception failure) { throw new IOException("Could not persist HTTP client credential", failure); }
+ }
+
+ private static void writeProfile(Path directory, HttpClientProfile profile) throws IOException {
+ Properties properties = new Properties();
+ properties.setProperty("version", "1");
+ properties.setProperty("serverId", profile.serverId());
+ properties.setProperty("endpoint", profile.endpoint().toASCIIString());
+ properties.setProperty("serverPin", profile.serverCertificatePin());
+ properties.setProperty("caPin", profile.caCertificatePin());
+ java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
+ properties.store(bytes, "VotingPlugin HTTP transport profile");
+ writePrivate(safe(directory.resolve(PROFILE_FILE)), bytes.toByteArray());
+ }
+
+ public static ClientCredential load(Path directory) throws Exception {
+ return loadCredential(activeDirectory(directory));
+ }
+
+ private static ClientCredential loadCredential(Path directory) throws Exception {
+ Path bundle = safe(directory.resolve(BUNDLE_FILE));
+ Path passwordFile = safe(directory.resolve(PASSWORD_FILE));
+ if (!Files.isRegularFile(bundle, LinkOption.NOFOLLOW_LINKS) || !Files.isRegularFile(passwordFile, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP client certificate has not been enrolled");
+ byte[] passwordBytes = Files.readAllBytes(passwordFile);
+ if (passwordBytes.length < 40 || passwordBytes.length > 128) throw new IOException("HTTP client password is invalid");
+ char[] password = new String(passwordBytes, StandardCharsets.US_ASCII).toCharArray();
+ java.util.Arrays.fill(passwordBytes, (byte) 0);
+ try {
+ KeyStore store = KeyStore.getInstance("PKCS12");
+ try (var input = Files.newInputStream(bundle, LinkOption.NOFOLLOW_LINKS)) { store.load(input, password); }
+ PrivateKey privateKey = (PrivateKey) store.getKey("client", password);
+ java.security.cert.Certificate[] chain = store.getCertificateChain("client");
+ if (privateKey == null || chain == null || chain.length != 2
+ || !(chain[0] instanceof X509Certificate client) || !(chain[1] instanceof X509Certificate authority))
+ throw new IOException("HTTP client certificate bundle is invalid");
+ return new ClientCredential(privateKey, client, authority, password);
+ } finally { java.util.Arrays.fill(password, '\0'); }
+ }
+
+ /** Writes and validates a replacement generation without touching the active credential. */
+ static StagedCredential stageReplacement(Path directory, HttpTlsIdentity.IssuedClientCertificate issued) throws Exception {
+ Path active = activeDirectory(directory);
+ return stage(directory, issued, loadProfileFile(active), readConnectionCodeDigest(active));
+ }
+
+ private static StagedCredential stage(Path directory, HttpTlsIdentity.IssuedClientCertificate issued,
+ HttpClientProfile profile, String connectionCodeDigest) throws Exception {
+ if (directory == null || issued == null) throw new IllegalArgumentException("Credential replacement is required");
+ Path credentialDirectory = credentialRoot(directory, true);
+ Path generations = credentialDirectory.resolve(GENERATIONS_DIRECTORY);
+ if (Files.isSymbolicLink(generations)) throw new IOException("HTTP credential generation directory is unsafe");
+ boolean generationsCreated = !Files.exists(generations, LinkOption.NOFOLLOW_LINKS);
+ Files.createDirectories(generations);
+ if (Files.isSymbolicLink(generations) || !Files.isDirectory(generations, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP credential generation directory is unsafe");
+ setOwnerOnlyDirectory(generations);
+ if (generationsCreated) DurableFiles.forceDirectory(credentialDirectory);
+ String name = java.util.UUID.randomUUID().toString();
+ Path generation = generations.resolve(name);
+ Files.createDirectory(generation);
+ setOwnerOnlyDirectory(generation);
+ try {
+ save(generation, issued);
+ ClientCredential replacement = loadCredential(generation);
+ profile = new HttpClientProfile(profile.serverId(), profile.endpoint(), profile.serverCertificatePin(),
+ HttpTransportSecrets.certificatePin(replacement.caCertificate()));
+ writeProfile(generation, profile);
+ if (connectionCodeDigest != null) writePrivate(safe(generation.resolve(CONNECTION_CODE_DIGEST_FILE)),
+ connectionCodeDigest.getBytes(StandardCharsets.US_ASCII));
+ EnrolledClient enrolled = loadEnrolled(generation);
+ // Each file is durable within the generation, but the generation name is
+ // published by its parent. Persist it before CURRENT can activate it.
+ DurableFiles.forceDirectory(generations);
+ return new StagedCredential(name, enrolled.credential(), enrolled.profile());
+ } catch (Exception failure) {
+ try { Files.deleteIfExists(generation.resolve(BUNDLE_FILE)); Files.deleteIfExists(generation.resolve(PASSWORD_FILE));
+ Files.deleteIfExists(generation.resolve(PROFILE_FILE)); Files.deleteIfExists(generation.resolve(CONNECTION_CODE_DIGEST_FILE));
+ Files.deleteIfExists(generation); }
+ catch (IOException cleanup) { failure.addSuppressed(cleanup); }
+ throw failure;
+ }
+ }
+
+ /** Atomically makes a fully validated generation durable and active. */
+ static void activateReplacement(Path directory, StagedCredential staged) throws IOException {
+ if (directory == null || staged == null || !staged.name().matches("[0-9a-f-]{36}"))
+ throw new IllegalArgumentException("Staged credential is invalid");
+ directory = credentialRoot(directory, false);
+ Path generations = directory.resolve(GENERATIONS_DIRECTORY);
+ if (Files.isSymbolicLink(generations) || !Files.isDirectory(generations, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP credential generation directory is unsafe");
+ Path generation = generations.resolve(staged.name()).normalize();
+ if (!generation.getParent().equals(generations) || Files.isSymbolicLink(generation)
+ || !Files.isRegularFile(generation.resolve(BUNDLE_FILE), LinkOption.NOFOLLOW_LINKS)
+ || !Files.isRegularFile(generation.resolve(PASSWORD_FILE), LinkOption.NOFOLLOW_LINKS)
+ || !Files.isRegularFile(generation.resolve(PROFILE_FILE), LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("Staged HTTP credential is incomplete");
+ writePrivate(safe(directory.resolve(CURRENT_FILE)), staged.name().getBytes(StandardCharsets.US_ASCII));
+ }
+
+ static record StagedCredential(String name, ClientCredential credential, HttpClientProfile profile) { }
+
+ public static HttpClientProfile loadProfile(Path directory) throws IOException {
+ return loadProfileFile(activeDirectory(directory));
+ }
+
+ private static HttpClientProfile loadProfileFile(Path directory) throws IOException {
+ Path profile = safe(directory.resolve(PROFILE_FILE));
+ if (!Files.isRegularFile(profile, LinkOption.NOFOLLOW_LINKS) || Files.size(profile) > 8192)
+ throw new IOException("HTTP transport profile has not been enrolled");
+ Properties properties = new Properties();
+ try (var input = Files.newInputStream(profile, LinkOption.NOFOLLOW_LINKS)) { properties.load(input); }
+ if (properties.size() != 5 || !"1".equals(properties.getProperty("version")))
+ throw new IOException("HTTP transport profile is invalid");
+ try {
+ return new HttpClientProfile(properties.getProperty("serverId"), URI.create(properties.getProperty("endpoint")),
+ properties.getProperty("serverPin"), properties.getProperty("caPin"));
+ } catch (IllegalArgumentException failure) { throw new IOException("HTTP transport profile is invalid", failure); }
+ }
+
+ public static boolean hasEnrolledProfile(Path directory) {
+ try { loadEnrolled(directory); return true; }
+ catch (Exception unavailable) { return false; }
+ }
+
+ /** Returns whether this exact one-time code created the active credential, without persisting the code itself. */
+ public static boolean matchesEnrollmentCode(Path directory, HttpConnectionCode code) throws IOException {
+ if (code == null) throw new IllegalArgumentException("Connection code is required");
+ String stored = readConnectionCodeDigest(activeDirectory(directory));
+ if (stored == null) return false;
+ byte[] storedBytes = stored.getBytes(StandardCharsets.US_ASCII);
+ return HttpTransportSecrets.constantTimeEquals(storedBytes,
+ connectionCodeDigest(code.encode()).getBytes(StandardCharsets.US_ASCII))
+ || HttpTransportSecrets.constantTimeEquals(storedBytes,
+ connectionCodeDigest(code.encodeLegacy()).getBytes(StandardCharsets.US_ASCII));
+ }
+
+ /** Loads and cross-checks the persisted client key material and bound normal-transport profile. */
+ public static EnrolledClient loadEnrolled(Path directory) throws Exception {
+ Path active = activeDirectory(directory);
+ return loadEnrolledDirectory(active);
+ }
+
+ private static EnrolledClient loadEnrolledDirectory(Path active) throws Exception {
+ ClientCredential credential = loadCredential(active);
+ HttpClientProfile profile = loadProfileFile(active);
+ if (!matchesProfile(credential, profile)) throw new IOException("HTTP client certificate does not match its profile");
+ return new EnrolledClient(profile, credential);
+ }
+
+ /** Captures the exact credential generation used by a transport before a staged replacement starts. */
+ public static ActiveCredentialGeneration snapshotActiveGeneration(Path directory) throws Exception {
+ Path root = directory.toAbsolutePath().normalize();
+ Path active = activeDirectory(root);
+ EnrolledClient enrolled = loadEnrolledDirectory(active);
+ return new ActiveCredentialGeneration(active.equals(root) ? "" : active.getFileName().toString(),
+ enrolled.profile(), readConnectionCodeDigest(active));
+ }
+
+ /**
+ * Restores a pre-replacement generation unless the replacement already activated a
+ * newer credential for the same backend endpoint. A successful renewal or same-endpoint
+ * re-enrollment may revoke the snapshotted certificate at the proxy, so that newer
+ * generation is the only safe rollback identity.
+ */
+ public static void restoreActiveGenerationAfterReplacement(Path directory,
+ ActiveCredentialGeneration snapshot) throws Exception {
+ if (directory == null || snapshot == null) throw new IllegalArgumentException("Credential rollback is required");
+ HttpClientProfile previous = snapshot.profile();
+ Path active = null;
+ HttpClientProfile current = null;
+ if (previous != null) try {
+ active = activeDirectory(directory);
+ current = loadEnrolledDirectory(active).profile();
+ } catch (Exception unavailable) { active = null; current = null; }
+ if (active != null && previous.serverId().equals(current.serverId())
+ && previous.endpoint().equals(current.endpoint())) {
+ restoreConnectionCodeDigest(active, snapshot.connectionCodeDigest());
+ return;
+ }
+ restoreActiveGeneration(directory, snapshot);
+ }
+
+ /** Atomically restores a previously validated credential generation after replacement rollback. */
+ public static void restoreActiveGeneration(Path directory, ActiveCredentialGeneration snapshot) throws Exception {
+ if (directory == null || snapshot == null) throw new IllegalArgumentException("Credential rollback is required");
+ Path root = credentialRoot(directory, false);
+ Path generations = root.resolve(GENERATIONS_DIRECTORY);
+ if (!snapshot.name().isEmpty() && (Files.isSymbolicLink(generations)
+ || !Files.isDirectory(generations, LinkOption.NOFOLLOW_LINKS)))
+ throw new IOException("HTTP credential generation directory is unsafe");
+ Path target = snapshot.name().isEmpty() ? root : generations.resolve(snapshot.name()).normalize();
+ if (!snapshot.name().isEmpty() && (!target.getParent().equals(generations)
+ || Files.isSymbolicLink(target) || !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)))
+ throw new IOException("HTTP client credential generation is invalid");
+ loadEnrolledDirectory(target);
+ Path current = safe(root.resolve(CURRENT_FILE));
+ if (snapshot.name().isEmpty()) {
+ Files.deleteIfExists(current);
+ DurableFiles.forceDirectory(root);
+ } else {
+ writePrivate(current, snapshot.name().getBytes(StandardCharsets.US_ASCII));
+ }
+ }
+
+ public record ClientCredential(PrivateKey privateKey, X509Certificate certificate, X509Certificate caCertificate, char[] password) {
+ public ClientCredential { password = password.clone(); }
+ @Override public char[] password() { return password.clone(); }
+ }
+
+ public record HttpClientProfile(String serverId, URI endpoint, String serverCertificatePin, String caCertificatePin) {
+ public HttpClientProfile {
+ serverId = HttpTlsIdentity.canonicalServerId(serverId);
+ HttpConnectionCode validation = new HttpConnectionCode(serverId, endpoint, serverCertificatePin, caCertificatePin,
+ java.time.Instant.now().plusSeconds(1), HttpTransportSecrets.randomToken());
+ endpoint = validation.endpoint();
+ serverCertificatePin = validation.serverCertificatePin();
+ caCertificatePin = validation.caCertificatePin();
+ }
+ }
+
+ public record EnrolledClient(HttpClientProfile profile, ClientCredential credential) { }
+
+ public record ActiveCredentialGeneration(String name, HttpClientProfile profile, String connectionCodeDigest) {
+ public ActiveCredentialGeneration(String name) { this(name, null, null); }
+ public ActiveCredentialGeneration {
+ if (name == null || (!name.isEmpty() && !name.matches("[0-9a-f-]{36}")))
+ throw new IllegalArgumentException("HTTP client credential generation is invalid");
+ if (connectionCodeDigest != null && !connectionCodeDigest.matches("[0-9a-f]{64}"))
+ throw new IllegalArgumentException("HTTP connection-code marker is invalid");
+ }
+ }
+
+ private static boolean matchesProfile(ClientCredential credential, HttpClientProfile profile) {
+ try {
+ String authorityPin = HttpTransportSecrets.certificatePin(credential.caCertificate());
+ if (!HttpTransportSecrets.constantTimeEquals(profile.caCertificatePin().getBytes(StandardCharsets.US_ASCII),
+ authorityPin.getBytes(StandardCharsets.US_ASCII))) return false;
+ credential.certificate().checkValidity();
+ credential.certificate().verify(credential.caCertificate().getPublicKey());
+ java.util.List usage = credential.certificate().getExtendedKeyUsage();
+ boolean[] keyUsage = credential.certificate().getKeyUsage();
+ if (usage == null || !usage.contains(org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_clientAuth.getId())
+ || keyUsage == null || !keyUsage[0]) return false;
+ String expected = "urn:votingplugin:http-backend:" + profile.serverId();
+ var names = credential.certificate().getSubjectAlternativeNames();
+ if (names == null) return false;
+ for (java.util.List> name : names) if (name.size() == 2
+ && Integer.valueOf(6).equals(name.get(0)) && expected.equals(name.get(1))) return true;
+ return false;
+ } catch (Exception invalid) { return false; }
+ }
+
+ private static Path safe(Path file) throws IOException {
+ if (Files.isSymbolicLink(file)) throw new IOException("Refusing unsafe HTTP credential path");
+ return file.toAbsolutePath().normalize();
+ }
+
+ private static Path activeDirectory(Path directory) throws IOException {
+ Path root = credentialRoot(directory, false);
+ Path current = safe(root.resolve(CURRENT_FILE));
+ if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) return root;
+ if (!Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS) || Files.size(current) > 64)
+ throw new IOException("HTTP client credential pointer is invalid");
+ String name = Files.readString(current, StandardCharsets.US_ASCII);
+ if (!name.matches("[0-9a-f-]{36}")) throw new IOException("HTTP client credential pointer is invalid");
+ Path generations = root.resolve(GENERATIONS_DIRECTORY);
+ if (Files.isSymbolicLink(generations) || !Files.isDirectory(generations, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP credential generation directory is unsafe");
+ Path generation = generations.resolve(name).normalize();
+ if (!generation.getParent().equals(generations) || Files.isSymbolicLink(generation) || !Files.isDirectory(generation, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP client credential generation is invalid");
+ return generation;
+ }
+
+ private static String readConnectionCodeDigest(Path directory) throws IOException {
+ Path digest = safe(directory.resolve(CONNECTION_CODE_DIGEST_FILE));
+ if (!Files.exists(digest, LinkOption.NOFOLLOW_LINKS)) return null;
+ if (!Files.isRegularFile(digest, LinkOption.NOFOLLOW_LINKS) || Files.size(digest) != 64)
+ throw new IOException("HTTP connection-code marker is invalid");
+ String value = Files.readString(digest, StandardCharsets.US_ASCII);
+ if (!value.matches("[0-9a-f]{64}")) throw new IOException("HTTP connection-code marker is invalid");
+ return value;
+ }
+
+ private static Path credentialRoot(Path directory, boolean create) throws IOException {
+ if (directory == null) throw new IllegalArgumentException("Credential directory is required");
+ Path root = directory.toAbsolutePath().normalize();
+ if (Files.isSymbolicLink(root)) throw new IOException("HTTP credential directory is unsafe");
+ boolean created = !Files.exists(root, LinkOption.NOFOLLOW_LINKS);
+ if (create) Files.createDirectories(root);
+ if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP credential directory is unsafe");
+ if (create) {
+ setOwnerOnlyDirectory(root);
+ if (created) DurableFiles.forceDirectory(root.getParent());
+ }
+ return root;
+ }
+
+ private static String connectionCodeDigest(HttpConnectionCode code) {
+ return connectionCodeDigest(code.encode());
+ }
+
+ private static String connectionCodeDigest(String encoded) {
+ return HttpTransportSecrets.sha256Hex(encoded.getBytes(StandardCharsets.US_ASCII));
+ }
+
+ private static void restoreConnectionCodeDigest(Path directory, String digest) throws IOException {
+ Path marker = safe(directory.resolve(CONNECTION_CODE_DIGEST_FILE));
+ if (digest == null) {
+ Files.deleteIfExists(marker);
+ DurableFiles.forceDirectory(directory);
+ } else {
+ writePrivate(marker, digest.getBytes(StandardCharsets.US_ASCII));
+ }
+ }
+
+ private static void writePrivate(Path file, byte[] contents) throws IOException {
+ Path temporary = Files.createTempFile(file.getParent(), file.getFileName().toString(), ".tmp");
+ try {
+ setOwnerOnly(temporary);
+ Files.write(temporary, contents, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
+ DurableFiles.forceFile(temporary);
+ try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
+ catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); }
+ setOwnerOnly(file);
+ DurableFiles.forceDirectory(file.getParent());
+ } finally { Files.deleteIfExists(temporary); }
+ }
+
+ private static void setOwnerOnly(Path path) throws IOException {
+ try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); }
+ catch (UnsupportedOperationException ignored) { }
+ }
+
+ private static void setOwnerOnlyDirectory(Path path) throws IOException {
+ try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); }
+ catch (UnsupportedOperationException ignored) { }
+ }
+
+ private static byte[] asciiBytes(char[] characters) {
+ byte[] output = new byte[characters.length];
+ for (int index = 0; index < characters.length; index++) output[index] = (byte) characters[index];
+ return output;
+ }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpConnectionCode.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpConnectionCode.java
new file mode 100644
index 0000000..872bf4b
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpConnectionCode.java
@@ -0,0 +1,112 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Locale;
+
+/**
+ * A copy/paste connection code. It deliberately includes no long-lived credential:
+ * its only secret is a short-lived, single-use enrollment token. The trailing MAC is a
+ * corruption check keyed by that included token; it is not a proxy signature and cannot stop
+ * someone who can replace the whole code from replacing it with another valid code.
+ */
+public record HttpConnectionCode(String serverId, URI endpoint, String serverCertificatePin, String caCertificatePin,
+ Instant expiresAt, String enrollmentToken) {
+ private static final String LEGACY_VERSION = "VPH1";
+ private static final String VERSION = "VPH2";
+ private static final int MAX_CODE_LENGTH = 4096;
+
+ public HttpConnectionCode {
+ serverId = HttpTlsIdentity.canonicalServerId(serverId);
+ endpoint = validateEndpoint(endpoint);
+ serverCertificatePin = validatePin(serverCertificatePin, "server certificate pin");
+ caCertificatePin = validatePin(caCertificatePin, "CA certificate pin");
+ if (expiresAt == null) throw new IllegalArgumentException("Expiry is required");
+ enrollmentToken = validateToken(enrollmentToken);
+ }
+
+ public String encode() {
+ String serverPart = Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(serverId.getBytes(StandardCharsets.UTF_8));
+ return encode(VERSION, serverPart);
+ }
+
+ String encodeLegacy() {
+ return encode(LEGACY_VERSION, serverId);
+ }
+
+ private String encode(String version, String serverPart) {
+ String endpointPart = Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(endpoint.toASCIIString().getBytes(StandardCharsets.UTF_8));
+ String unsigned = String.join(".", version, serverPart, endpointPart, serverCertificatePin, caCertificatePin,
+ Long.toString(expiresAt.getEpochSecond()), enrollmentToken);
+ byte[] token = Base64.getUrlDecoder().decode(enrollmentToken);
+ return unsigned + "." + HttpTransportSecrets.hmacSha256Url(token, unsigned);
+ }
+
+ public boolean isActive(Clock clock) {
+ return expiresAt.isAfter(clock.instant());
+ }
+
+ public void requireActive(Clock clock) {
+ if (!isActive(clock)) throw new IllegalArgumentException("Connection code has expired");
+ }
+
+ public static HttpConnectionCode parse(String code) {
+ if (code == null || code.length() > MAX_CODE_LENGTH || code.indexOf('\n') >= 0 || code.indexOf('\r') >= 0)
+ throw new IllegalArgumentException("Connection code is invalid");
+ String[] parts = code.split("\\.", -1);
+ if (parts.length != 8 || (!VERSION.equals(parts[0]) && !LEGACY_VERSION.equals(parts[0])))
+ throw new IllegalArgumentException("Connection code is invalid");
+ try {
+ String serverId = LEGACY_VERSION.equals(parts[0]) ? parts[1]
+ : new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
+ String endpoint = new String(Base64.getUrlDecoder().decode(parts[2]), StandardCharsets.UTF_8);
+ String unsigned = String.join(".", parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]);
+ byte[] token = Base64.getUrlDecoder().decode(parts[6]);
+ String expected = HttpTransportSecrets.hmacSha256Url(token, unsigned);
+ if (!HttpTransportSecrets.constantTimeEquals(expected.getBytes(StandardCharsets.US_ASCII),
+ parts[7].getBytes(StandardCharsets.US_ASCII))) throw new IllegalArgumentException("Connection code is invalid");
+ return new HttpConnectionCode(serverId, new URI(endpoint), parts[3], parts[4], Instant.ofEpochSecond(Long.parseLong(parts[5])),
+ parts[6]);
+ } catch (IllegalArgumentException | URISyntaxException failure) {
+ throw new IllegalArgumentException("Connection code is invalid", failure);
+ }
+ }
+
+ private static URI validateEndpoint(URI value) {
+ if (value == null || !"https".equalsIgnoreCase(value.getScheme()) || value.getHost() == null
+ || value.getUserInfo() != null || value.getFragment() != null || value.getRawQuery() != null)
+ throw new IllegalArgumentException("Endpoint must be an absolute HTTPS URL without credentials or query");
+ if (value.getPort() == 0 || value.getPort() > 65535 || value.getPort() < -1)
+ throw new IllegalArgumentException("Endpoint port is invalid");
+ String path = value.getRawPath();
+ if (path == null || path.isEmpty()) path = "/";
+ if (!path.endsWith("/")) path += "/";
+ try {
+ return new URI("https", null, value.getHost().toLowerCase(Locale.ROOT), value.getPort(), path, null, null);
+ } catch (URISyntaxException failure) {
+ throw new IllegalArgumentException("Endpoint is invalid", failure);
+ }
+ }
+
+ private static String validatePin(String pin, String name) {
+ if (pin == null || !pin.matches("[0-9a-fA-F]{64}")) throw new IllegalArgumentException(name + " is invalid");
+ return pin.toLowerCase(Locale.ROOT);
+ }
+
+ private static String validateToken(String token) {
+ if (token == null || token.length() < 43 || token.length() > 128 || !token.matches("[A-Za-z0-9_-]+"))
+ throw new IllegalArgumentException("Enrollment token is invalid");
+ try {
+ if (Base64.getUrlDecoder().decode(token).length < 32) throw new IllegalArgumentException("Enrollment token is invalid");
+ return token;
+ } catch (IllegalArgumentException failure) {
+ throw new IllegalArgumentException("Enrollment token is invalid", failure);
+ }
+ }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpEnrollmentAuthority.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpEnrollmentAuthority.java
new file mode 100644
index 0000000..00ecfc3
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpEnrollmentAuthority.java
@@ -0,0 +1,283 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Base64;
+import java.util.EnumSet;
+import com.bencodez.simpleapi.file.DurableFiles;
+
+/**
+ * Single-use enrollment tokens and client-certificate binding. Token material is never retained;
+ * only SHA-256 hashes are kept until expiry. This type is thread-safe.
+ */
+public final class HttpEnrollmentAuthority {
+ private static final Duration MAX_ENROLLMENT_LIFETIME = Duration.ofMinutes(15);
+ private static final int MAX_PENDING_ENROLLMENTS = 128;
+ private static final int MAX_BINDINGS = 128;
+ private static final long MAX_STATE_BYTES = 65536;
+ private final HttpTlsIdentity identity;
+ private final Clock clock;
+ private final Path stateFile;
+ private final Map enrollments = new HashMap<>();
+ private final Map bindings = new HashMap<>();
+ private boolean persistenceFailure;
+ private boolean revocationRetryRequired;
+
+ /** Creates a restart-safe authority. State contains public certificate pins plus bounded hashes of pending tokens. */
+ public HttpEnrollmentAuthority(HttpTlsIdentity identity, Path stateDirectory) throws java.io.IOException {
+ this(identity, Clock.systemUTC(), stateFile(stateDirectory));
+ loadState();
+ }
+
+ HttpEnrollmentAuthority(HttpTlsIdentity identity, Clock clock) {
+ this(identity, clock, null);
+ }
+
+ HttpEnrollmentAuthority(HttpTlsIdentity identity, Clock clock, Path stateFile) {
+ if (identity == null || clock == null) throw new IllegalArgumentException("Identity and clock are required");
+ this.identity = identity;
+ this.clock = clock;
+ this.stateFile = stateFile;
+ }
+
+ public synchronized HttpConnectionCode createConnectionCode(String serverId, URI endpoint, Duration lifetime) {
+ serverId = HttpTlsIdentity.canonicalServerId(serverId);
+ if (lifetime == null || lifetime.isNegative() || lifetime.isZero() || lifetime.compareTo(MAX_ENROLLMENT_LIFETIME) > 0)
+ throw new IllegalArgumentException("Enrollment lifetime must be between one second and fifteen minutes");
+ expireEnrollments();
+ if (enrollments.size() >= MAX_PENDING_ENROLLMENTS)
+ throw new IllegalStateException("Too many pending HTTP enrollments");
+ Instant expiresAt = clock.instant().plus(lifetime);
+ String token = HttpTransportSecrets.randomToken();
+ byte[] tokenHash = HttpTransportSecrets.sha256(token.getBytes(StandardCharsets.US_ASCII));
+ String lookup = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(tokenHash);
+ enrollments.put(lookup, new Enrollment(tokenHash, expiresAt, serverId));
+ try { persistState(); }
+ catch (java.io.IOException failure) {
+ enrollments.remove(lookup);
+ throw new IllegalStateException("Could not persist HTTP enrollment", failure);
+ }
+ return new HttpConnectionCode(serverId, endpoint, identity.serverCertificatePin(), identity.caCertificatePin(), expiresAt, token);
+ }
+
+ public synchronized HttpTlsIdentity.IssuedClientCertificate enroll(String serverId, String enrollmentToken) throws Exception {
+ serverId = HttpTlsIdentity.canonicalServerId(serverId);
+ if (enrollmentToken == null || enrollmentToken.length() > 128) throw new IllegalArgumentException("Enrollment was rejected");
+ expireEnrollments();
+ byte[] suppliedHash = HttpTransportSecrets.sha256(enrollmentToken.getBytes(StandardCharsets.US_ASCII));
+ String lookup = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(suppliedHash);
+ Enrollment enrollment = enrollments.get(lookup);
+ if (enrollment == null || !HttpTransportSecrets.constantTimeEquals(enrollment.tokenHash(), suppliedHash))
+ throw new IllegalArgumentException("Enrollment was rejected");
+ if (!serverId.equals(enrollment.serverId())) throw new IllegalArgumentException("Enrollment was rejected");
+ ClientBinding existing = bindings.get(serverId);
+ if (existing != null && !existing.revoked()) throw new IllegalStateException("Server id is already enrolled");
+ if (existing == null && bindings.size() >= MAX_BINDINGS)
+ throw new IllegalStateException("Too many enrolled HTTP backends");
+ enrollments.remove(lookup); // consume only after all checks for the token and its intended backend pass.
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate(serverId);
+ bindings.put(serverId, new ClientBinding(HttpTransportSecrets.certificatePin(issued.certificate()), null, false));
+ try { persistState(); }
+ catch (java.io.IOException failure) { persistenceFailure = true; throw failure; }
+ return issued;
+ }
+
+ public synchronized boolean authenticate(String serverId, java.security.cert.X509Certificate certificate) {
+ if (persistenceFailure || serverId == null || certificate == null) return false;
+ try { serverId = HttpTlsIdentity.canonicalServerId(serverId); }
+ catch (IllegalArgumentException invalid) { return false; }
+ if (!identity.validClientCertificate(serverId, certificate)) return false;
+ ClientBinding binding = bindings.get(serverId);
+ String pin = HttpTransportSecrets.certificatePin(certificate);
+ if (binding == null || binding.revoked()) return false;
+ if (samePin(binding.certificatePin(), pin)) return true;
+ if (!samePin(binding.pendingCertificatePin(), pin)) return false;
+ bindings.put(serverId, new ClientBinding(pin, null, false));
+ try { persistState(); return true; }
+ catch (java.io.IOException failure) { persistenceFailure = true; return false; }
+ }
+
+ /** Issues a replacement while the currently bound certificate is still valid. The old binding remains active
+ * until the replacement successfully authenticates, making a lost renewal response safe to retry. */
+ public synchronized HttpTlsIdentity.IssuedClientCertificate renew(String serverId,
+ java.security.cert.X509Certificate currentCertificate) throws Exception {
+ if (!authenticate(serverId, currentCertificate)) throw new IllegalArgumentException("Certificate renewal was rejected");
+ serverId = HttpTlsIdentity.canonicalServerId(serverId);
+ ClientBinding binding = bindings.get(serverId);
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate(serverId);
+ bindings.put(serverId, new ClientBinding(binding.certificatePin(),
+ HttpTransportSecrets.certificatePin(issued.certificate()), false));
+ try { persistState(); }
+ catch (java.io.IOException failure) { persistenceFailure = true; throw failure; }
+ return issued;
+ }
+
+ public synchronized void revoke(String serverId) {
+ try { serverId = HttpTlsIdentity.canonicalServerId(serverId); }
+ catch (IllegalArgumentException invalid) { return; }
+ final String revokedServer = serverId;
+ Map removedEnrollments = new HashMap<>();
+ enrollments.entrySet().removeIf(entry -> {
+ if (!revokedServer.equals(entry.getValue().serverId())) return false;
+ removedEnrollments.put(entry.getKey(), entry.getValue());
+ return true;
+ });
+ // Absence is the durable revocation fence: authentication always requires an exact active binding.
+ ClientBinding removedBinding = bindings.remove(serverId);
+ if (removedBinding != null || !removedEnrollments.isEmpty() || revocationRetryRequired) try {
+ persistState();
+ revocationRetryRequired = false;
+ }
+ catch (java.io.IOException failure) {
+ // Before publication, restore the exact disk-backed state so a retry still has work to persist.
+ // After publication, retain the fail-closed new state and let a retry force it durably again.
+ if (!(failure instanceof DurableFiles.PublishedException)) {
+ if (removedBinding != null) bindings.put(serverId, removedBinding);
+ enrollments.putAll(removedEnrollments);
+ }
+ persistenceFailure = true;
+ revocationRetryRequired = true;
+ throw new IllegalStateException("Could not persist HTTP certificate revocation", failure);
+ }
+ }
+
+ private synchronized void loadState() throws java.io.IOException {
+ if (stateFile == null || !Files.exists(stateFile, LinkOption.NOFOLLOW_LINKS)) return;
+ if (!Files.isRegularFile(stateFile, LinkOption.NOFOLLOW_LINKS) || Files.size(stateFile) > MAX_STATE_BYTES)
+ throw new java.io.IOException("HTTP enrollment state is invalid");
+ Properties properties = new Properties();
+ try (var input = Files.newInputStream(stateFile, LinkOption.NOFOLLOW_LINKS)) { properties.load(input); }
+ String version = properties.getProperty("version");
+ if (!("1".equals(version) || "2".equals(version) || "3".equals(version)))
+ throw new java.io.IOException("HTTP enrollment state is invalid");
+ for (String key : properties.stringPropertyNames()) {
+ if (key.startsWith("binding.")) {
+ String serverId = new String(Base64.getUrlDecoder().decode(key.substring("binding.".length())), StandardCharsets.UTF_8);
+ serverId = HttpTlsIdentity.canonicalServerId(serverId);
+ String[] value = properties.getProperty(key, "").split(":", -1);
+ if (!((value.length == 2 && "1".equals(version))
+ || (value.length == 3 && ("2".equals(version) || "3".equals(version))))
+ || !value[0].matches("[0-9a-f]{64}"))
+ throw new java.io.IOException("HTTP enrollment state is invalid");
+ String pending = value.length == 3 && !"-".equals(value[1]) ? value[1] : null;
+ String revoked = value[value.length - 1];
+ if ((pending != null && !pending.matches("[0-9a-f]{64}")) || !("0".equals(revoked) || "1".equals(revoked)))
+ throw new java.io.IOException("HTTP enrollment state is invalid");
+ if ("0".equals(revoked)) {
+ if (bindings.size() >= MAX_BINDINGS) throw new java.io.IOException("HTTP enrollment state exceeds its bound");
+ bindings.put(serverId, new ClientBinding(value[0], pending, false));
+ }
+ } else if (key.startsWith("enrollment.") && "3".equals(version)) {
+ String lookup = key.substring("enrollment.".length());
+ if (!lookup.matches("[A-Za-z0-9_-]{43}")) throw new java.io.IOException("HTTP enrollment state is invalid");
+ byte[] tokenHash;
+ try { tokenHash = Base64.getUrlDecoder().decode(lookup); }
+ catch (IllegalArgumentException invalid) { throw new java.io.IOException("HTTP enrollment state is invalid", invalid); }
+ if (tokenHash.length != 32) throw new java.io.IOException("HTTP enrollment state is invalid");
+ String[] value = properties.getProperty(key, "").split(":", -1);
+ if (value.length != 2) throw new java.io.IOException("HTTP enrollment state is invalid");
+ Instant expiresAt;
+ String serverId;
+ try {
+ expiresAt = Instant.ofEpochMilli(Long.parseLong(value[0]));
+ serverId = HttpTlsIdentity.canonicalServerId(new String(Base64.getUrlDecoder().decode(value[1]), StandardCharsets.UTF_8));
+ } catch (RuntimeException invalid) { throw new java.io.IOException("HTTP enrollment state is invalid", invalid); }
+ if (expiresAt.isAfter(clock.instant())) {
+ if (enrollments.size() >= MAX_PENDING_ENROLLMENTS)
+ throw new java.io.IOException("HTTP enrollment state exceeds its bound");
+ enrollments.put(lookup, new Enrollment(tokenHash, expiresAt, serverId));
+ }
+ } else if (!"version".equals(key)) throw new java.io.IOException("HTTP enrollment state is invalid");
+ }
+ }
+
+ private synchronized void persistState() throws java.io.IOException {
+ if (stateFile == null) return;
+ if (bindings.size() > MAX_BINDINGS || enrollments.size() > MAX_PENDING_ENROLLMENTS)
+ throw new java.io.IOException("HTTP enrollment state exceeds its bound");
+ Properties properties = new Properties();
+ properties.setProperty("version", "3");
+ for (Map.Entry entry : bindings.entrySet()) {
+ String key = Base64.getUrlEncoder().withoutPadding().encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8));
+ properties.setProperty("binding." + key, entry.getValue().certificatePin() + ":"
+ + (entry.getValue().pendingCertificatePin() == null ? "-" : entry.getValue().pendingCertificatePin())
+ + ":" + (entry.getValue().revoked() ? "1" : "0"));
+ }
+ for (Map.Entry entry : enrollments.entrySet()) {
+ String server = Base64.getUrlEncoder().withoutPadding().encodeToString(
+ entry.getValue().serverId().getBytes(StandardCharsets.UTF_8));
+ properties.setProperty("enrollment." + entry.getKey(),
+ entry.getValue().expiresAt().toEpochMilli() + ":" + server);
+ }
+ java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
+ properties.store(bytes, "VotingPlugin HTTP transport authority state");
+ if (bytes.size() > MAX_STATE_BYTES) throw new java.io.IOException("HTTP enrollment state exceeds its byte bound");
+ Path temporary = Files.createTempFile(stateFile.getParent(), stateFile.getFileName().toString(), ".tmp");
+ try {
+ setOwnerOnly(temporary);
+ Files.write(temporary, bytes.toByteArray(), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
+ DurableFiles.forceFile(temporary);
+ try { Files.move(temporary, stateFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
+ catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING); }
+ try {
+ setOwnerOnly(stateFile);
+ DurableFiles.forceDirectory(stateFile.getParent());
+ } catch (java.io.IOException postPublicationFailure) {
+ throw new DurableFiles.PublishedException(postPublicationFailure);
+ }
+ } finally { Files.deleteIfExists(temporary); }
+ }
+
+ private static Path stateFile(Path directory) throws java.io.IOException {
+ if (directory == null) throw new IllegalArgumentException("State directory is required");
+ Path stateDirectory = directory.toAbsolutePath().normalize();
+ boolean created = !Files.exists(stateDirectory, LinkOption.NOFOLLOW_LINKS);
+ Files.createDirectories(stateDirectory);
+ if (Files.isSymbolicLink(stateDirectory) || !Files.isDirectory(stateDirectory, LinkOption.NOFOLLOW_LINKS))
+ throw new java.io.IOException("HTTP enrollment state directory is unsafe");
+ setOwnerOnlyDirectory(stateDirectory);
+ if (created) DurableFiles.forceDirectory(stateDirectory.getParent());
+ Path file = stateDirectory.resolve("http-transport-clients.properties");
+ if (Files.isSymbolicLink(file)) throw new java.io.IOException("Refusing unsafe HTTP enrollment state path");
+ return file;
+ }
+
+ private static void setOwnerOnly(Path path) throws java.io.IOException {
+ try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); }
+ catch (UnsupportedOperationException ignored) { }
+ }
+
+ private static void setOwnerOnlyDirectory(Path path) throws java.io.IOException {
+ try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); }
+ catch (UnsupportedOperationException ignored) { }
+ }
+
+ private void expireEnrollments() {
+ Instant now = clock.instant();
+ enrollments.entrySet().removeIf(entry -> !entry.getValue().expiresAt().isAfter(now));
+ }
+
+ private record Enrollment(byte[] tokenHash, Instant expiresAt, String serverId) {
+ private Enrollment { tokenHash = tokenHash.clone(); }
+ @Override public byte[] tokenHash() { return tokenHash.clone(); }
+ }
+ private static boolean samePin(String expected, String actual) {
+ return expected != null && actual != null && HttpTransportSecrets.constantTimeEquals(
+ expected.getBytes(StandardCharsets.US_ASCII), actual.getBytes(StandardCharsets.US_ASCII));
+ }
+
+ private record ClientBinding(String certificatePin, String pendingCertificatePin, boolean revoked) { }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpInboundDeliveryStore.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpInboundDeliveryStore.java
new file mode 100644
index 0000000..3166d2c
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpInboundDeliveryStore.java
@@ -0,0 +1,215 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import com.bencodez.simpleapi.file.DurableFiles;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.EnumSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/** Crash-durable state for proxy deliveries around a non-transactional application callback. */
+final class HttpInboundDeliveryStore {
+ private static final String DIRECTORY = "http-transport-inbound-deliveries";
+ private static final int MAX_ENTRIES = HttpTransportProtocol.MAX_QUEUE;
+ private final Path root;
+ private final Map entries = new LinkedHashMap<>();
+ private boolean sealed;
+
+ HttpInboundDeliveryStore(Path credentialDirectory) throws IOException {
+ this(credentialDirectory, DIRECTORY);
+ }
+
+ static HttpInboundDeliveryStore open(Path parent, String directoryName) throws IOException {
+ return new HttpInboundDeliveryStore(parent, directoryName);
+ }
+
+ private HttpInboundDeliveryStore(Path parent, String directoryName) throws IOException {
+ Path credentials = parent.toAbsolutePath().normalize();
+ if (Files.isSymbolicLink(credentials) || !Files.isDirectory(credentials, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP credential directory is unsafe");
+ if (directoryName == null || !directoryName.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"))
+ throw new IOException("HTTP inbound delivery directory name is invalid");
+ ownerOnlyDirectory(credentials);
+ root = credentials.resolve(directoryName).normalize();
+ if (!root.getParent().equals(credentials)) throw new IOException("HTTP inbound delivery directory is invalid");
+ boolean created = false;
+ try { Files.createDirectory(root); created = true; }
+ catch (java.nio.file.FileAlreadyExistsException existing) { }
+ try {
+ requireRoot();
+ ownerOnlyDirectory(root);
+ } finally {
+ if (created) DurableFiles.forceDirectory(credentials);
+ }
+ load();
+ }
+
+ synchronized State state(String id) { return entries.get(canonical(id)); }
+
+ synchronized void reserve(String id) throws IOException {
+ requireWritable();
+ id = canonical(id);
+ State existing = entries.get(id);
+ if (existing == State.RESERVED) return;
+ if (existing != null) throw new IOException("HTTP inbound delivery fence is already active");
+ if (entries.size() >= MAX_ENTRIES) throw new IOException("HTTP inbound delivery fence is full");
+ requireRoot();
+ Path target = file(id, State.RESERVED);
+ if (Files.exists(target, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP inbound delivery fence is inconsistent");
+ Path temporary = Files.createTempFile(root, ".pending-", ".tmp");
+ try {
+ ownerOnlyFile(temporary);
+ Files.writeString(temporary, id, StandardCharsets.US_ASCII, StandardOpenOption.TRUNCATE_EXISTING);
+ DurableFiles.forceFile(temporary);
+ move(temporary, target);
+ ownerOnlyFile(target);
+ DurableFiles.forceDirectory(root);
+ entries.put(id, State.RESERVED);
+ } finally { Files.deleteIfExists(temporary); }
+ }
+
+ /** Records receipt of a remote acknowledgement before its confirmation is sent. */
+ synchronized void recordCompleted(String id) throws IOException {
+ requireWritable();
+ id = canonical(id);
+ if (entries.get(id) == State.COMPLETED) return;
+ if (entries.containsKey(id) || entries.size() >= MAX_ENTRIES)
+ throw new IOException("HTTP acknowledgement confirmation fence is full");
+ requireRoot();
+ Path target = file(id, State.COMPLETED);
+ Path temporary = Files.createTempFile(root, ".pending-", ".tmp");
+ try {
+ ownerOnlyFile(temporary);
+ Files.writeString(temporary, id, StandardCharsets.US_ASCII, StandardOpenOption.TRUNCATE_EXISTING);
+ DurableFiles.forceFile(temporary);
+ move(temporary, target);
+ ownerOnlyFile(target);
+ DurableFiles.forceDirectory(root);
+ entries.put(id, State.COMPLETED);
+ } finally { Files.deleteIfExists(temporary); }
+ }
+
+ synchronized void markRunning(String id) throws IOException { transition(id, State.RESERVED, State.RUNNING); }
+ synchronized void markCompleted(String id) throws IOException { transition(id, State.RUNNING, State.COMPLETED); }
+ synchronized void seal() { sealed = true; }
+ synchronized void sealAndDeleteIfEmpty() throws IOException {
+ if (!entries.isEmpty()) throw new IOException("HTTP inbound delivery store is not empty");
+ requireRoot();
+ try (DirectoryStream files = Files.newDirectoryStream(root)) {
+ if (files.iterator().hasNext()) throw new IOException("HTTP inbound delivery directory is not empty");
+ }
+ sealed = true;
+ Path parent = root.getParent();
+ Files.delete(root);
+ DurableFiles.forceDirectory(parent);
+ }
+
+ synchronized void remove(String id) throws IOException {
+ requireWritable();
+ id = canonical(id);
+ State state = entries.get(id);
+ if (state == null) return;
+ requireRoot();
+ DurableFiles.deleteIfExists(file(id, state));
+ entries.remove(id);
+ }
+
+ synchronized Map snapshot() { return Map.copyOf(entries); }
+
+ private void transition(String id, State expected, State replacement) throws IOException {
+ requireWritable();
+ id = canonical(id);
+ if (entries.get(id) != expected) throw new IOException("HTTP inbound delivery fence state is invalid");
+ requireRoot();
+ Path source = file(id, expected), target = file(id, replacement);
+ if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)
+ || Files.exists(target, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP inbound delivery fence state is unsafe");
+ move(source, target);
+ DurableFiles.forceDirectory(root);
+ entries.put(id, replacement);
+ }
+
+ private void load() throws IOException {
+ try (DirectoryStream files = Files.newDirectoryStream(root)) {
+ for (Path file : files) {
+ String name = file.getFileName().toString();
+ if (name.startsWith(".pending-") && name.endsWith(".tmp") && !Files.isSymbolicLink(file)
+ && Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
+ DurableFiles.deleteIfExists(file);
+ continue;
+ }
+ State state = State.fromFileName(name);
+ if (state == null || Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
+ || Files.size(file) > 64L)
+ throw new IOException("HTTP inbound delivery fence contains an invalid entry");
+ String id;
+ try { id = canonical(name.substring(0, name.length() - state.suffix.length())); }
+ catch (IllegalArgumentException invalid) {
+ throw new IOException("HTTP inbound delivery fence entry is invalid", invalid);
+ }
+ if (!name.equals(id + state.suffix) || !Files.readString(file, StandardCharsets.US_ASCII).equals(id))
+ throw new IOException("HTTP inbound delivery fence entry is invalid");
+ State existing = entries.get(id);
+ if (existing == null) entries.put(id, state);
+ else {
+ // A provider without atomic moves may expose both names after an
+ // interrupted transition. Preserve the furthest fail-closed state:
+ // RUNNING never replays, and COMPLETED alone may be acknowledged.
+ State retained = existing.ordinal() >= state.ordinal() ? existing : state;
+ State obsolete = retained == existing ? state : existing;
+ DurableFiles.deleteIfExists(file(id, obsolete));
+ entries.put(id, retained);
+ }
+ if (entries.size() > MAX_ENTRIES) throw new IOException("HTTP inbound delivery fence exceeds its bound");
+ }
+ }
+ }
+
+ private Path file(String id, State state) { return root.resolve(id + state.suffix); }
+ private void requireWritable() throws IOException {
+ if (sealed) throw new IOException("HTTP inbound delivery store ownership has ended");
+ }
+ private static void move(Path source, Path target) throws IOException {
+ try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); }
+ catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(source, target); }
+ }
+ private static String canonical(String id) {
+ if (id == null) throw new IllegalArgumentException("HTTP delivery id is invalid");
+ String canonical = UUID.fromString(id).toString();
+ if (!canonical.equals(id)) throw new IllegalArgumentException("HTTP delivery id is not canonical");
+ return canonical;
+ }
+ private void requireRoot() throws IOException {
+ if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP inbound delivery directory is unsafe");
+ }
+ private static void ownerOnlyFile(Path path) throws IOException {
+ try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); }
+ catch (UnsupportedOperationException ignored) { }
+ }
+ private static void ownerOnlyDirectory(Path path) throws IOException {
+ try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); }
+ catch (UnsupportedOperationException ignored) { }
+ }
+
+ enum State {
+ RESERVED(".reserved"), RUNNING(".running"), COMPLETED(".completed");
+ private final String suffix;
+ State(String suffix) { this.suffix = suffix; }
+ private static State fromFileName(String name) {
+ for (State state : values()) if (name.endsWith(state.suffix)) return state;
+ return null;
+ }
+ }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpPinnedTls.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpPinnedTls.java
new file mode 100644
index 0000000..4240897
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpPinnedTls.java
@@ -0,0 +1,103 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import java.security.cert.X509Certificate;
+import java.security.KeyStore;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
+/** Builds the backend TLS context. A public CA store is intentionally not consulted. */
+public final class HttpPinnedTls {
+ private HttpPinnedTls() { }
+
+ public static SSLContext clientContext(HttpConnectionCode code) throws Exception {
+ if (code == null) throw new IllegalArgumentException("Connection code is required");
+ SSLContext context = SSLContext.getInstance("TLS");
+ context.init(null, new TrustManager[] { new PinnedServerTrustManager(code.serverCertificatePin(), code.caCertificatePin()) }, null);
+ return context;
+ }
+
+ /**
+ * Normal transport context: presents the enrolled client certificate and trusts only the
+ * pinned private authority. Callers must not override the HttpClient default endpoint-identification
+ * settings; hostname verification remains enabled and the proxy leaf may renew under the same CA.
+ */
+ public static SSLContext mutualTlsContext(HttpConnectionCode code, HttpClientCredentialStore.ClientCredential credential)
+ throws Exception {
+ if (code == null || credential == null || credential.privateKey() == null || credential.certificate() == null || credential.caCertificate() == null)
+ throw new IllegalArgumentException("Enrolled client credential is required");
+ char[] password = credential.password();
+ try {
+ String authorityPin = HttpTransportSecrets.certificatePin(credential.caCertificate());
+ if (!HttpTransportSecrets.constantTimeEquals(code.caCertificatePin().getBytes(java.nio.charset.StandardCharsets.US_ASCII),
+ authorityPin.getBytes(java.nio.charset.StandardCharsets.US_ASCII)))
+ throw new IllegalArgumentException("HTTP authority does not match connection code");
+ KeyStore store = KeyStore.getInstance("PKCS12");
+ store.load(null, new char[0]);
+ store.setKeyEntry("client", credential.privateKey(), password,
+ new java.security.cert.Certificate[] { credential.certificate(), credential.caCertificate() });
+ KeyManagerFactory managers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ managers.init(store, password);
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, new char[0]);
+ trustStore.setCertificateEntry("http-transport-ca", credential.caCertificate());
+ javax.net.ssl.TrustManagerFactory trusts = javax.net.ssl.TrustManagerFactory.getInstance(
+ javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm());
+ trusts.init(trustStore);
+ SSLContext context = SSLContext.getInstance("TLS");
+ context.init(managers.getKeyManagers(), trusts.getTrustManagers(), null);
+ return context;
+ } finally { java.util.Arrays.fill(password, '\0'); }
+ }
+
+ public static boolean matchesServerPin(HttpConnectionCode code, X509Certificate certificate) {
+ if (code == null || certificate == null) return false;
+ String actual = HttpTransportSecrets.certificatePin(certificate);
+ return HttpTransportSecrets.constantTimeEquals(code.serverCertificatePin()
+ .getBytes(java.nio.charset.StandardCharsets.US_ASCII), actual.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
+ }
+
+ /** TLS 1.3 is used where the runtime exposes it; hostname verification is deliberately left enabled. */
+ public static SSLParameters secureParameters(SSLContext context) {
+ SSLParameters parameters = context.getDefaultSSLParameters();
+ for (String protocol : context.getSupportedSSLParameters().getProtocols()) {
+ if ("TLSv1.3".equals(protocol)) {
+ parameters.setProtocols(new String[] { "TLSv1.3" });
+ break;
+ }
+ }
+ return parameters;
+ }
+
+ private static final class PinnedServerTrustManager implements X509TrustManager {
+ private final String expectedPin;
+ private final String expectedCaPin;
+ private PinnedServerTrustManager(String expectedPin, String expectedCaPin) {
+ this.expectedPin = expectedPin;
+ this.expectedCaPin = expectedCaPin;
+ }
+ @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { throw new UnsupportedOperationException(); }
+ @Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws java.security.cert.CertificateException {
+ if (chain == null || chain.length < 2) throw new java.security.cert.CertificateException("Server certificate chain is incomplete");
+ chain[0].checkValidity();
+ chain[chain.length - 1].checkValidity();
+ try { chain[0].verify(chain[chain.length - 1].getPublicKey()); }
+ catch (java.security.GeneralSecurityException invalid) {
+ throw new java.security.cert.CertificateException("Server certificate signature is invalid", invalid);
+ }
+ if (chain[chain.length - 1].getBasicConstraints() < 0)
+ throw new java.security.cert.CertificateException("Server certificate authority is invalid");
+ String actual = HttpTransportSecrets.certificatePin(chain[0]);
+ if (!HttpTransportSecrets.constantTimeEquals(expectedPin.getBytes(java.nio.charset.StandardCharsets.US_ASCII),
+ actual.getBytes(java.nio.charset.StandardCharsets.US_ASCII)))
+ throw new java.security.cert.CertificateException("Server certificate pin does not match");
+ String issuer = HttpTransportSecrets.certificatePin(chain[chain.length - 1]);
+ if (!HttpTransportSecrets.constantTimeEquals(expectedCaPin.getBytes(java.nio.charset.StandardCharsets.US_ASCII),
+ issuer.getBytes(java.nio.charset.StandardCharsets.US_ASCII)))
+ throw new java.security.cert.CertificateException("Server certificate authority pin does not match");
+ }
+ @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
+ }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpProxyTransportServer.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpProxyTransportServer.java
new file mode 100644
index 0000000..e289a44
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpProxyTransportServer.java
@@ -0,0 +1,636 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
+import com.bencodez.simpleapi.file.DurableFiles;
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpsConfigurator;
+import com.sun.net.httpserver.HttpsExchange;
+import com.sun.net.httpserver.HttpsParameters;
+import com.sun.net.httpserver.HttpsServer;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.LongSupplier;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLPeerUnverifiedException;
+
+/**
+ * One HTTPS listener for enrollment and the backend-to-proxy long-poll transport.
+ * Every normal request is certificate-authenticated in the handler, rather than relying on TLS WANT auth.
+ */
+public final class HttpProxyTransportServer implements AutoCloseable {
+ private static final int MAX_BACKENDS = 128;
+ private static final long BACKEND_REPLAY_RETENTION_NANOS =
+ TimeUnit.MILLISECONDS.toNanos(HttpTransportProtocol.MAX_CLOCK_SKEW_MILLIS) + 1L;
+ static {
+ // JDK HttpServer reads these once when its internal server configuration is initialized.
+ // Set conservative process-wide bounds before this transport creates its listener.
+ setDefault("sun.net.httpserver.maxReqTime", "10");
+ setDefault("sun.net.httpserver.maxRspTime", "10");
+ setDefault("jdk.httpserver.maxConnections", "144");
+ setDefault("sun.net.httpserver.maxReqHeaders", "32");
+ setDefault("sun.net.httpserver.maxReqHeaderSize", "16384");
+ }
+ // Keep an idle request open long enough to reuse the TLS connection, but bound backend-origin
+ // latency when a message is queued immediately after the request body has already been sent.
+ public static final Duration LONG_POLL = Duration.ofSeconds(2);
+ private final HttpTlsIdentity identity;
+ private final HttpEnrollmentAuthority authority;
+ private final HttpsServer server;
+ private final ThreadPoolExecutor listenerExecutor;
+ private final ThreadPoolExecutor handlerExecutor;
+ private final Semaphore admission = new Semaphore(64);
+ private final Map backends = new HashMap<>();
+ private final DurableOutgoingQueue durableOutgoing;
+ private final Path durableIncomingRoot;
+ private final Consumer onEnvelope;
+ private final DeliveryAcknowledgement onAcknowledged;
+ private final LongSupplier nanoTime;
+ private volatile boolean closed;
+
+ /** In-memory constructor for tests; production callers must supply a durable state directory. */
+ HttpProxyTransportServer(InetSocketAddress bind, HttpTlsIdentity identity, HttpEnrollmentAuthority authority,
+ Consumer onEnvelope) throws Exception {
+ this(bind, identity, authority, null, onEnvelope, (serverId, deliveryId) -> { });
+ }
+
+ public HttpProxyTransportServer(InetSocketAddress bind, HttpTlsIdentity identity, HttpEnrollmentAuthority authority,
+ Path outgoingDirectory, Consumer onEnvelope) throws Exception {
+ this(bind, identity, authority, outgoingDirectory, onEnvelope, (serverId, deliveryId) -> { });
+ }
+
+ public HttpProxyTransportServer(InetSocketAddress bind, HttpTlsIdentity identity, HttpEnrollmentAuthority authority,
+ Path outgoingDirectory, Consumer onEnvelope,
+ DeliveryAcknowledgement onAcknowledged) throws Exception {
+ this(bind, identity, authority, outgoingDirectory, onEnvelope, onAcknowledged, System::nanoTime);
+ }
+
+ HttpProxyTransportServer(InetSocketAddress bind, HttpTlsIdentity identity, HttpEnrollmentAuthority authority,
+ Path outgoingDirectory, Consumer onEnvelope,
+ DeliveryAcknowledgement onAcknowledged, LongSupplier nanoTime) throws Exception {
+ if (bind == null || identity == null || authority == null || onEnvelope == null || onAcknowledged == null)
+ throw new IllegalArgumentException("HTTP transport configuration is required");
+ if (nanoTime == null) throw new IllegalArgumentException("HTTP transport clock is required");
+ this.identity = identity; this.authority = authority; this.onEnvelope = onEnvelope;
+ this.onAcknowledged = onAcknowledged; this.nanoTime = nanoTime;
+ durableOutgoing = outgoingDirectory == null ? null : new DurableOutgoingQueue(outgoingDirectory);
+ durableIncomingRoot = outgoingDirectory == null ? null : incomingRoot(outgoingDirectory);
+ if (durableOutgoing != null) for (Map.Entry> pending
+ : durableOutgoing.load().entrySet()) {
+ BackendState state = backendState(pending.getKey());
+ state.restore(pending.getValue());
+ }
+ server = HttpsServer.create(bind, 32);
+ server.setHttpsConfigurator(new HttpsConfigurator(identity.serverContext()) {
+ @Override public void configure(HttpsParameters parameters) {
+ SSLParameters ssl = HttpPinnedTls.secureParameters(getSSLContext());
+ ssl.setWantClientAuth(true); parameters.setSSLParameters(ssl);
+ }
+ });
+ // Long polls are blocking by design. Capacity is bounded by admission, while enough workers
+ // remain available for all admitted polls plus setup requests.
+ listenerExecutor = executor("SimpleAPI-HTTP-listener", 72, 72);
+ // The proxy router mutates shared presence, vote, and reward state. A separate
+ // bounded FIFO lane keeps wire order without blocking long-poll workers.
+ handlerExecutor = executor("SimpleAPI-HTTP-handler", 1, HttpBackendTransportConnector.CALLBACK_QUEUE_CAPACITY);
+ server.setExecutor(listenerExecutor);
+ server.createContext("/v1/enroll", exchange -> enroll((HttpsExchange) exchange));
+ server.createContext("/v1/renew", exchange -> renew((HttpsExchange) exchange));
+ server.createContext("/v1/transport", exchange -> transport((HttpsExchange) exchange));
+ }
+
+ private void renew(HttpsExchange exchange) throws IOException {
+ if (!"/v1/renew".equals(exchange.getRequestURI().getPath()) || exchange.getRequestURI().getRawQuery() != null) { reply(exchange, 404, new byte[0]); return; }
+ if (!"POST".equals(exchange.getRequestMethod())) { reply(exchange, 405, new byte[0]); return; }
+ if (!json(exchange)) { reply(exchange, 415, new byte[0]); return; }
+ if (!boundedFixedBody(exchange, 1024) || !admission.tryAcquire()) { reply(exchange, 429, new byte[0]); return; }
+ try {
+ String serverId = HttpTransportProtocol.parseRenewal(read(exchange.getRequestBody(), 1024));
+ X509Certificate certificate = peerCertificate(exchange);
+ if (certificate == null || !authority.authenticate(serverId, certificate)) { reply(exchange, 401, new byte[0]); return; }
+ HttpTlsIdentity.IssuedClientCertificate issued = authority.renew(serverId, certificate);
+ reply(exchange, 201, HttpTransportProtocol.enrollmentResponse(issued));
+ } catch (IllegalArgumentException rejected) { reply(exchange, 403, new byte[0]);
+ } catch (Exception failure) { reply(exchange, 503, new byte[0]);
+ } finally { admission.release(); }
+ }
+
+ public void start() { if (closed) throw new IllegalStateException("HTTP transport is closed"); server.start(); }
+ public int port() { return server.getAddress().getPort(); }
+ public URI endpoint(String host) {
+ if (host == null || host.isBlank()) throw new IllegalArgumentException("Advertised host is required");
+ try {
+ URI endpoint = new URI("https", null, host, port(), "/", null, null);
+ if (endpoint.getHost() == null) throw new IllegalArgumentException("Advertised host is invalid");
+ return endpoint;
+ } catch (java.net.URISyntaxException invalid) {
+ throw new IllegalArgumentException("Advertised host is invalid", invalid);
+ }
+ }
+
+ /** Queues a proxy-origin envelope durably before reporting acceptance. */
+ public boolean send(String serverId, JsonEnvelope envelope) {
+ return send(serverId, UUID.randomUUID().toString(), envelope);
+ }
+
+ /** Queues a proxy-origin envelope with a stable, caller-persisted delivery ID. */
+ public boolean send(String serverId, String deliveryId, JsonEnvelope envelope) {
+ if (closed || serverId == null || envelope == null) return false;
+ try {
+ serverId = HttpTlsIdentity.canonicalServerId(serverId);
+ HttpTransportProtocol.validId(deliveryId);
+ HttpTransportProtocol.validateEnvelope(envelope);
+ }
+ catch (IllegalArgumentException invalid) { return false; }
+ BackendState backend;
+ final String canonicalServerId = serverId;
+ try { backend = backendState(canonicalServerId); }
+ catch (IOException persistenceFailure) { return false; }
+ return backend.enqueue(new HttpTransportProtocol.Delivery(deliveryId, envelope));
+ }
+
+ @Override public void close() {
+ if (closed) return; closed = true; server.stop(1);
+ shutdown(handlerExecutor); shutdown(listenerExecutor);
+ synchronized (backends) { for (BackendState backend : backends.values()) { backend.seal(); backend.signal(); } backends.clear(); }
+ }
+
+ private void enroll(HttpsExchange exchange) throws IOException {
+ if (!"/v1/enroll".equals(exchange.getRequestURI().getPath()) || exchange.getRequestURI().getRawQuery() != null) { reply(exchange, 404, new byte[0]); return; }
+ if (!"POST".equals(exchange.getRequestMethod())) { reply(exchange, 405, new byte[0]); return; }
+ if (!json(exchange)) { reply(exchange, 415, new byte[0]); return; }
+ if (!boundedFixedBody(exchange, 8192) || !admission.tryAcquire()) { reply(exchange, 429, new byte[0]); return; }
+ try {
+ HttpTransportProtocol.Enrollment request = HttpTransportProtocol.parseEnrollment(read(exchange.getRequestBody(), 8192));
+ HttpTlsIdentity.IssuedClientCertificate issued = authority.enroll(request.server(), request.token());
+ reply(exchange, 201, HttpTransportProtocol.enrollmentResponse(issued));
+ } catch (Exception rejected) { reply(exchange, 403, new byte[0]); }
+ finally { admission.release(); }
+ }
+
+ private void transport(HttpsExchange exchange) throws IOException {
+ if (!"/v1/transport".equals(exchange.getRequestURI().getPath()) || exchange.getRequestURI().getRawQuery() != null) { reply(exchange, 404, new byte[0]); return; }
+ if (!"POST".equals(exchange.getRequestMethod())) { reply(exchange, 405, new byte[0]); return; }
+ if (!json(exchange)) { reply(exchange, 415, new byte[0]); return; }
+ if (!boundedFixedBody(exchange, HttpTransportProtocol.MAX_BODY_BYTES) || !admission.tryAcquire()) { reply(exchange, 429, new byte[0]); return; }
+ try {
+ HttpTransportProtocol.Packet packet = HttpTransportProtocol.parsePacket(read(exchange.getRequestBody(), HttpTransportProtocol.MAX_BODY_BYTES));
+ X509Certificate certificate = peerCertificate(exchange);
+ if (certificate == null || !authority.authenticate(packet.server(), certificate)) { reply(exchange, 401, new byte[0]); return; }
+ BackendState backend;
+ backend = backendState(packet.server());
+ if (!backend.beginPoll(packet.session())) { reply(exchange, 409, new byte[0]); return; }
+ try {
+ handlePacket(packet, backend);
+ Response response = backend.await(packet.server(), packet.session(), packet.sequence(), packet.acks());
+ reply(exchange, 200, HttpTransportProtocol.response(packet.server(), packet.session(), packet.sequence(),
+ response.acks(), packet.acks(), response.messages()));
+ } finally { backend.endPoll(); }
+ } catch (IllegalArgumentException rejected) { reply(exchange, 400, new byte[0]);
+ } catch (Exception failure) { reply(exchange, 503, new byte[0]);
+ } finally { admission.release(); }
+ }
+
+ private void handlePacket(HttpTransportProtocol.Packet packet, BackendState backend) throws IOException {
+ List accepted;
+ synchronized (backend) {
+ if (!backend.allowRequest()) throw new IllegalArgumentException("transport rate limited");
+ if (!backend.acceptSession(packet.session(), packet.sequence())) throw new IllegalArgumentException("stale session request");
+ }
+ backend.confirmIncoming(packet.ackConfirmations());
+ backend.acknowledge(packet.acks());
+ synchronized (backend) { accepted = backend.acceptIncoming(packet.messages()); }
+ for (HttpTransportProtocol.Delivery delivery : accepted) dispatch(packet.server(), backend, delivery);
+ }
+ private void dispatch(String serverId, BackendState backend, HttpTransportProtocol.Delivery delivery) {
+ Runnable callback = () -> {
+ boolean success = false;
+ try {
+ backend.beginIncoming(delivery.id());
+ onEnvelope.accept(new ReceivedEnvelope(serverId, delivery.id(), normalizeBackendIdentity(serverId, delivery.envelope())));
+ backend.completeIncomingDurably(delivery.id());
+ success = true;
+ }
+ catch (IOException persistenceFailure) { }
+ catch (RuntimeException ignored) { }
+ synchronized (backend) { backend.completeIncoming(delivery.id(), success); }
+ };
+ if (!HttpBackendTransportConnector.executeOrdered(handlerExecutor, callback))
+ synchronized (backend) { backend.completeIncoming(delivery.id(), false); }
+ }
+ private BackendState backendState(String serverId) throws IOException {
+ synchronized (backends) {
+ BackendState existing = backends.get(serverId);
+ if (existing != null) return existing;
+ if (backends.size() >= MAX_BACKENDS) reclaimInactiveBackend();
+ if (backends.size() >= MAX_BACKENDS) throw new IOException("HTTP backend state exceeds its bound");
+ HttpInboundDeliveryStore inbound = durableIncomingRoot == null ? null
+ : HttpInboundDeliveryStore.open(durableIncomingRoot, serverId);
+ BackendState created = new BackendState(serverId, durableOutgoing, inbound, onAcknowledged, nanoTime);
+ backends.put(serverId, created);
+ return created;
+ }
+ }
+ private void reclaimInactiveBackend() throws IOException {
+ long now = nanoTime.getAsLong();
+ for (Iterator> iterator = backends.entrySet().iterator(); iterator.hasNext();) {
+ BackendState state = iterator.next().getValue();
+ if (!state.retireIfQuiescent(now, BACKEND_REPLAY_RETENTION_NANOS)) continue;
+ iterator.remove();
+ return;
+ }
+ }
+ BackendState backendStateForTest(String serverId) throws IOException { return backendState(serverId); }
+ int backendCountForTest() { synchronized (backends) { return backends.size(); } }
+ private static Path incomingRoot(Path outgoingDirectory) throws IOException {
+ Path outgoing = outgoingDirectory.toAbsolutePath().normalize();
+ Path parent = outgoing.getParent();
+ if (parent == null || outgoing.getFileName() == null) throw new IOException("HTTP incoming queue path is invalid");
+ Path root = parent.resolve(outgoing.getFileName().toString() + "-incoming");
+ boolean created = false;
+ try { Files.createDirectory(root); created = true; }
+ catch (java.nio.file.FileAlreadyExistsException existing) { }
+ if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP incoming queue directory is invalid");
+ DurableOutgoingQueue.ownerOnlyDirectory(root);
+ if (created) DurableFiles.forceDirectory(parent);
+ return root;
+ }
+ private static JsonEnvelope normalizeBackendIdentity(String serverId, JsonEnvelope envelope) {
+ // The authenticated TLS identity is authoritative; never forward a forged `server` field.
+ return envelope.toBuilder().put("server", serverId).build();
+ }
+ private static X509Certificate peerCertificate(HttpsExchange exchange) {
+ try { Certificate[] peer = exchange.getSSLSession().getPeerCertificates();
+ return peer.length > 0 && peer[0] instanceof X509Certificate certificate ? certificate : null;
+ } catch (SSLPeerUnverifiedException absent) { return null; }
+ }
+ private static byte[] read(InputStream input, int maximum) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; int total = 0, read;
+ while ((read = input.read(buffer)) >= 0) { total += read; if (total > maximum) throw new IllegalArgumentException("HTTP body is too large"); output.write(buffer, 0, read); }
+ return output.toByteArray();
+ }
+ private static void reply(HttpsExchange exchange, int status, byte[] body) throws IOException {
+ Headers headers = exchange.getResponseHeaders(); headers.set("Cache-Control", "no-store"); headers.set("Content-Type", "application/json; charset=utf-8");
+ exchange.sendResponseHeaders(status, body.length); try (var output = exchange.getResponseBody()) { output.write(body); }
+ }
+ private static boolean json(HttpsExchange exchange) {
+ String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
+ return contentType != null && contentType.toLowerCase(java.util.Locale.ROOT).matches("application/json(?:\\s*;.*)?");
+ }
+ private static boolean boundedFixedBody(HttpsExchange exchange, int maximum) {
+ if (exchange.getRequestHeaders().getFirst("Transfer-Encoding") != null) return false;
+ String value = exchange.getRequestHeaders().getFirst("Content-Length");
+ try { long length = Long.parseLong(value); return length > 0L && length <= maximum; }
+ catch (RuntimeException invalid) { return false; }
+ }
+ private static ThreadPoolExecutor executor(String name, int threads, int queue) {
+ ThreadFactory factory = task -> { Thread thread = new Thread(task, name); thread.setDaemon(true); return thread; };
+ return new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queue), factory, new ThreadPoolExecutor.AbortPolicy());
+ }
+ private static void setDefault(String name, String value) { if (System.getProperty(name) == null) System.setProperty(name, value); }
+ private static void shutdown(ExecutorService executor) { executor.shutdown(); try { if (!executor.awaitTermination(5, TimeUnit.SECONDS)) executor.shutdownNow(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); executor.shutdownNow(); } }
+
+ public record ReceivedEnvelope(String serverId, String messageId, JsonEnvelope envelope) { }
+
+ @FunctionalInterface
+ public interface DeliveryAcknowledgement {
+ void confirm(String serverId, String deliveryId) throws IOException;
+ }
+ static record Response(Collection acks, Collection messages) { }
+ static final class BackendState {
+ private final String serverId;
+ private final DurableOutgoingQueue durableOutgoing;
+ private final HttpInboundDeliveryStore durableIncoming;
+ private final DeliveryAcknowledgement onAcknowledged;
+ private final LongSupplier nanoTime;
+ private String session; private long sequence = -1L;
+ private final LinkedHashMap outgoing = new LinkedHashMap<>();
+ private final Set seen = new LinkedHashSet<>(); private final Set processing = new LinkedHashSet<>();
+ private final ArrayDeque acknowledgements = new ArrayDeque<>();
+ private final Map deliveredAtNanos = new HashMap<>();
+ private double requestTokens = 24.0d;
+ private long lastTokenNanos;
+ private long lastActivityNanos;
+ private boolean activePoll, retired;
+ BackendState() { this(null, null, null, (serverId, deliveryId) -> { }, System::nanoTime); }
+ BackendState(LongSupplier nanoTime) { this(null, null, null, (serverId, deliveryId) -> { }, nanoTime); }
+ private BackendState(String serverId, DurableOutgoingQueue durableOutgoing) {
+ this(serverId, durableOutgoing, null, (ignoredServer, ignoredDelivery) -> { }, System::nanoTime);
+ }
+ BackendState(String serverId, DurableOutgoingQueue durableOutgoing,
+ DeliveryAcknowledgement onAcknowledged) {
+ this(serverId, durableOutgoing, null, onAcknowledged, System::nanoTime);
+ }
+ BackendState(String serverId, DurableOutgoingQueue durableOutgoing, HttpInboundDeliveryStore durableIncoming,
+ DeliveryAcknowledgement onAcknowledged) {
+ this(serverId, durableOutgoing, durableIncoming, onAcknowledged, System::nanoTime);
+ }
+ private BackendState(String serverId, DurableOutgoingQueue durableOutgoing,
+ HttpInboundDeliveryStore durableIncoming, DeliveryAcknowledgement onAcknowledged, LongSupplier nanoTime) {
+ this.serverId = serverId; this.durableOutgoing = durableOutgoing;
+ this.durableIncoming = durableIncoming; this.onAcknowledged = onAcknowledged; this.nanoTime = nanoTime;
+ lastTokenNanos = lastActivityNanos = nanoTime.getAsLong();
+ if (durableIncoming != null) for (Map.Entry entry
+ : durableIncoming.snapshot().entrySet()) {
+ if (entry.getValue() == HttpInboundDeliveryStore.State.COMPLETED) {
+ seen.add(entry.getKey()); queueAck(entry.getKey());
+ }
+ }
+ }
+ private synchronized void restore(Collection deliveries) {
+ for (HttpTransportProtocol.Delivery delivery : deliveries) outgoing.put(delivery.id(), delivery);
+ }
+ private boolean beginPoll(String requestedSession) { synchronized (this) { if (retired || activePoll) return false; activePoll = true; touch(); return true; } }
+ private void endPoll() { synchronized (this) { activePoll = false; touch(); notifyAll(); } }
+ boolean beginPollForTest() { return beginPoll("test"); }
+ void endPollForTest() { endPoll(); }
+ private boolean allowRequest() {
+ long now = nanoTime.getAsLong(); requestTokens = Math.min(24.0d, requestTokens + ((now - lastTokenNanos) / 1_000_000_000.0d) * 2.0d);
+ lastTokenNanos = now; touch(); if (requestTokens < 1.0d) return false; requestTokens -= 1.0d; return true;
+ }
+ boolean acceptSession(String requested, long requestedSequence) {
+ if (!requested.equals(session)) { session = requested; sequence = -1L; deliveredAtNanos.clear(); }
+ // The connector allocates a fresh monotonic sequence for every attempt. Rejecting equality
+ // prevents a captured request from being replayed with altered ACKs or a new payload.
+ if (requestedSequence <= sequence) return false; sequence = requestedSequence; return true;
+ }
+ synchronized boolean enqueue(HttpTransportProtocol.Delivery delivery) {
+ if (retired) return false;
+ HttpTransportProtocol.Delivery existing = outgoing.get(delivery.id());
+ if (existing != null) return Arrays.equals(HttpTransportProtocol.storedDelivery(existing),
+ HttpTransportProtocol.storedDelivery(delivery));
+ if (outgoing.size() >= HttpTransportProtocol.MAX_QUEUE) return false;
+ if (durableOutgoing != null) try { durableOutgoing.persist(serverId, delivery); }
+ catch (IOException failure) { return false; }
+ outgoing.put(delivery.id(), delivery); touch(); signal(); return true;
+ }
+ void acknowledge(Collection acks) throws IOException {
+ for (String id : acks) {
+ synchronized (this) { if (!outgoing.containsKey(id)) continue; }
+ onAcknowledged.confirm(serverId, id);
+ synchronized (this) {
+ if (!outgoing.containsKey(id)) continue;
+ // A 200 response is the backend's proof that its durable replay fence may
+ // be deleted. Never return success while the proxy delivery still exists.
+ if (durableOutgoing != null) durableOutgoing.remove(serverId, id);
+ outgoing.remove(id); deliveredAtNanos.remove(id);
+ }
+ }
+ }
+ List acceptIncoming(List received) {
+ List accepted = new java.util.ArrayList<>();
+ for (HttpTransportProtocol.Delivery delivery : received) {
+ HttpInboundDeliveryStore.State persisted = durableIncoming == null ? null : durableIncoming.state(delivery.id());
+ if (seen.contains(delivery.id()) || persisted == HttpInboundDeliveryStore.State.COMPLETED) {
+ seen.add(delivery.id()); queueAck(delivery.id()); continue;
+ }
+ if (persisted == HttpInboundDeliveryStore.State.RUNNING) continue;
+ if (!processing.contains(delivery.id())) {
+ processing.add(delivery.id()); accepted.add(delivery);
+ }
+ }
+ return accepted;
+ }
+ void beginIncoming(String id) throws IOException {
+ if (durableIncoming == null) return;
+ if (durableIncoming.state(id) == null) durableIncoming.reserve(id);
+ durableIncoming.markRunning(id);
+ }
+ void completeIncomingDurably(String id) throws IOException {
+ if (durableIncoming != null) durableIncoming.markCompleted(id);
+ }
+ synchronized void completeIncoming(String id, boolean success) { processing.remove(id); if (success) { seen.add(id); while (seen.size() > HttpTransportProtocol.MAX_QUEUE) seen.remove(seen.iterator().next()); queueAck(id); signal(); } }
+ synchronized void confirmIncoming(Collection ids) throws IOException {
+ for (String id : ids) {
+ if (durableIncoming != null && durableIncoming.state(id) == HttpInboundDeliveryStore.State.COMPLETED)
+ durableIncoming.remove(id);
+ seen.remove(id);
+ acknowledgements.removeIf(id::equals);
+ }
+ }
+ private void seal() { if (durableIncoming != null) durableIncoming.seal(); }
+ private synchronized boolean retireIfQuiescent(long now, long retentionNanos) throws IOException {
+ if (retired || activePoll || !outgoing.isEmpty() || !deliveredAtNanos.isEmpty() || !seen.isEmpty()
+ || !processing.isEmpty() || !acknowledgements.isEmpty() || now - lastActivityNanos < retentionNanos
+ || durableIncoming != null && !durableIncoming.snapshot().isEmpty()) return false;
+ if (durableIncoming != null) durableIncoming.sealAndDeleteIfEmpty();
+ retired = true;
+ return true;
+ }
+ private void touch() { lastActivityNanos = nanoTime.getAsLong(); }
+ private void queueAck(String id) { if (acknowledgements.size() < HttpTransportProtocol.MAX_QUEUE && !acknowledgements.contains(id)) acknowledgements.add(id); }
+ synchronized Response await(String serverId, String requestedSession, long requestedSequence) {
+ return await(serverId, requestedSession, requestedSequence, List.of());
+ }
+ synchronized Response await(String serverId, String requestedSession, long requestedSequence,
+ Collection ackConfirmations) {
+ long deadline = System.nanoTime() + LONG_POLL.toNanos();
+ while (acknowledgements.isEmpty() && !hasUndelivered()) {
+ long retryRemaining = nanosUntilRedelivery(nanoTime.getAsLong());
+ if (retryRemaining <= 0L) break;
+ long requestRemaining = deadline - System.nanoTime(); if (requestRemaining <= 0L) break;
+ long wait = Math.min(requestRemaining, retryRemaining);
+ try { TimeUnit.NANOSECONDS.timedWait(this, wait); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); break; }
+ }
+ List acks = new java.util.ArrayList<>(); while (!acknowledgements.isEmpty() && acks.size() < HttpTransportProtocol.MAX_BATCH) acks.add(acknowledgements.remove());
+ List candidates = new java.util.ArrayList<>();
+ long now = nanoTime.getAsLong();
+ if (hasUndelivered() || redeliveryDue(now)) for (HttpTransportProtocol.Delivery delivery : outgoing.values()) {
+ if (!deliveredAtNanos.containsKey(delivery.id()) || redeliveryDue(delivery.id(), now)) candidates.add(delivery);
+ if (candidates.size() == HttpTransportProtocol.MAX_BATCH) break;
+ }
+ List messages = HttpTransportProtocol.fittingMessages(serverId, requestedSession,
+ requestedSequence, acks, ackConfirmations, candidates);
+ long deliveredAt = nanoTime.getAsLong();
+ for (HttpTransportProtocol.Delivery delivery : messages) deliveredAtNanos.put(delivery.id(), deliveredAt);
+ return new Response(acks, messages);
+ }
+ private boolean hasUndelivered() { for (String id : outgoing.keySet()) if (!deliveredAtNanos.containsKey(id)) return true; return false; }
+ private boolean redeliveryDue(long now) {
+ for (String id : outgoing.keySet()) if (redeliveryDue(id, now)) return true;
+ return false;
+ }
+ private boolean redeliveryDue(String id, long now) {
+ Long deliveredAt = deliveredAtNanos.get(id);
+ return deliveredAt != null && now - deliveredAt >= LONG_POLL.toNanos();
+ }
+ private long nanosUntilRedelivery(long now) {
+ long remaining = Long.MAX_VALUE;
+ for (String id : outgoing.keySet()) {
+ Long deliveredAt = deliveredAtNanos.get(id);
+ if (deliveredAt == null) continue;
+ long candidate = LONG_POLL.toNanos() - (now - deliveredAt);
+ if (candidate <= 0L) return 0L;
+ remaining = Math.min(remaining, candidate);
+ }
+ return remaining;
+ }
+ private synchronized void signal() { notifyAll(); }
+ }
+
+ private static final class DurableOutgoingQueue {
+ private static final String FILE_PATTERN = "[0-9]{20}-[0-9a-f-]{36}\\.json";
+ private final Path root;
+ private final Map> files = new HashMap<>();
+ private long sequence;
+
+ private DurableOutgoingQueue(Path root) throws IOException {
+ this.root = root.toAbsolutePath().normalize();
+ boolean created = false;
+ try { Files.createDirectory(this.root); created = true; }
+ catch (java.nio.file.FileAlreadyExistsException existing) { }
+ try {
+ if (Files.isSymbolicLink(this.root) || !Files.isDirectory(this.root, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP outgoing queue directory is invalid");
+ ownerOnlyDirectory(this.root);
+ } finally {
+ if (created) DurableFiles.forceDirectory(this.root.getParent());
+ }
+ }
+
+ private synchronized Map> load() throws IOException {
+ Map> loaded = new LinkedHashMap<>();
+ int serverDirectories = 0;
+ try (DirectoryStream servers = Files.newDirectoryStream(root)) {
+ for (Path directory : servers) {
+ if (++serverDirectories > MAX_BACKENDS) throw new IOException("HTTP outgoing queue exceeds its backend bound");
+ if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP outgoing queue contains an invalid entry");
+ String serverId;
+ try { serverId = HttpTlsIdentity.canonicalServerId(directory.getFileName().toString()); }
+ catch (IllegalArgumentException invalid) { throw new IOException("HTTP outgoing queue server is invalid", invalid); }
+ if (!serverId.equals(directory.getFileName().toString()))
+ throw new IOException("HTTP outgoing queue server is not canonical");
+ List entries = new java.util.ArrayList<>();
+ try (DirectoryStream messages = Files.newDirectoryStream(directory)) {
+ for (Path message : messages) entries.add(message);
+ }
+ entries.sort(java.util.Comparator.comparing(path -> path.getFileName().toString()));
+ List deliveries = new java.util.ArrayList<>();
+ Map serverFiles = files.computeIfAbsent(serverId, ignored -> new HashMap<>());
+ for (Path message : entries) {
+ String name = message.getFileName().toString();
+ if (name.startsWith(".pending-") && name.endsWith(".tmp")
+ && !Files.isSymbolicLink(message) && Files.isRegularFile(message, LinkOption.NOFOLLOW_LINKS)) {
+ DurableFiles.deleteIfExists(message);
+ continue;
+ }
+ if (Files.isSymbolicLink(message) || !Files.isRegularFile(message, LinkOption.NOFOLLOW_LINKS)
+ || !name.matches(FILE_PATTERN) || Files.size(message) > HttpTransportProtocol.MAX_ENVELOPE_BYTES * 2L)
+ throw new IOException("HTTP outgoing queue message is invalid");
+ HttpTransportProtocol.Delivery delivery;
+ try { delivery = HttpTransportProtocol.parseStoredDelivery(Files.readAllBytes(message)); }
+ catch (IllegalArgumentException invalid) { throw new IOException("HTTP outgoing queue message is invalid", invalid); }
+ if (!name.endsWith("-" + delivery.id() + ".json") || serverFiles.put(delivery.id(), message) != null)
+ throw new IOException("HTTP outgoing queue message id is invalid");
+ deliveries.add(delivery);
+ if (deliveries.size() > HttpTransportProtocol.MAX_QUEUE)
+ throw new IOException("HTTP outgoing queue exceeds its bound");
+ sequence = Math.max(sequence, Long.parseLong(name.substring(0, 20)));
+ }
+ if (!deliveries.isEmpty()) loaded.put(serverId, deliveries);
+ }
+ }
+ return loaded;
+ }
+
+ private synchronized void persist(String serverId, HttpTransportProtocol.Delivery delivery) throws IOException {
+ Path directory = root.resolve(serverId).normalize();
+ if (!directory.getParent().equals(root)) throw new IOException("HTTP outgoing queue server is invalid");
+ boolean created = false;
+ try { Files.createDirectory(directory); created = true; }
+ catch (java.nio.file.FileAlreadyExistsException existing) { }
+ try {
+ if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP outgoing queue server directory is invalid");
+ ownerOnlyDirectory(directory);
+ } finally {
+ // The child fsync below cannot make this newly published name durable in
+ // its parent. Persist the root entry before accepting the first message.
+ if (created) DurableFiles.forceDirectory(root);
+ }
+ if (sequence == Long.MAX_VALUE) throw new IOException("HTTP outgoing queue sequence is exhausted");
+ String name = String.format(java.util.Locale.ROOT, "%020d-%s.json", ++sequence, delivery.id());
+ Path target = directory.resolve(name);
+ Path temporary = Files.createTempFile(directory, ".pending-", ".tmp");
+ try {
+ ownerOnlyFile(temporary);
+ Files.write(temporary, HttpTransportProtocol.storedDelivery(delivery), StandardOpenOption.TRUNCATE_EXISTING);
+ DurableFiles.forceFile(temporary);
+ try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); }
+ catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, target); }
+ ownerOnlyFile(target); DurableFiles.forceDirectory(directory);
+ files.computeIfAbsent(serverId, ignored -> new HashMap<>()).put(delivery.id(), target);
+ } finally { Files.deleteIfExists(temporary); }
+ }
+
+ private synchronized void remove(String serverId, String id) throws IOException {
+ Map serverFiles = files.get(serverId);
+ if (serverFiles == null) throw new IOException("HTTP outgoing queue acknowledgement is unknown");
+ Path file = serverFiles.get(id);
+ if (file != null) {
+ Files.deleteIfExists(file);
+ // Also makes a retried deletion durable if an earlier directory force failed after unlinking the file.
+ DurableFiles.forceDirectory(file.getParent());
+ serverFiles.remove(id);
+ }
+ if (serverFiles.isEmpty()) {
+ Path directory = root.resolve(serverId).normalize();
+ if (!directory.getParent().equals(root) || Files.isSymbolicLink(directory))
+ throw new IOException("HTTP outgoing queue server directory is invalid");
+ try {
+ Files.deleteIfExists(directory);
+ DurableFiles.forceDirectory(root);
+ files.remove(serverId);
+ } catch (java.nio.file.DirectoryNotEmptyException unexpectedEntry) {
+ // The acknowledged delivery is already durably removed; unrelated/tampered entries
+ // must not make its acknowledgement permanently unprocessable.
+ }
+ }
+ }
+
+ private static void ownerOnlyFile(Path path) throws IOException {
+ try { Files.setPosixFilePermissions(path, java.util.EnumSet.of(
+ java.nio.file.attribute.PosixFilePermission.OWNER_READ,
+ java.nio.file.attribute.PosixFilePermission.OWNER_WRITE)); }
+ catch (UnsupportedOperationException ignored) { }
+ }
+ private static void ownerOnlyDirectory(Path path) throws IOException {
+ try { Files.setPosixFilePermissions(path, java.util.EnumSet.of(
+ java.nio.file.attribute.PosixFilePermission.OWNER_READ,
+ java.nio.file.attribute.PosixFilePermission.OWNER_WRITE,
+ java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE)); }
+ catch (UnsupportedOperationException ignored) { }
+ }
+ }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTlsIdentity.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTlsIdentity.java
new file mode 100644
index 0000000..3ba11fc
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTlsIdentity.java
@@ -0,0 +1,472 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.Principal;
+import java.security.Security;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Date;
+import java.util.EnumSet;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.net.Socket;
+import javax.net.ssl.KeyManager;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509ExtendedKeyManager;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
+import org.bouncycastle.asn1.x509.KeyPurposeId;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import com.bencodez.simpleapi.file.DurableFiles;
+
+/** Durable private CA plus server identity used by the proxy HTTP listener. */
+public final class HttpTlsIdentity {
+ private static final String CA_FILE = "http-transport-ca.p12";
+ private static final String SERVER_FILE = "http-transport-server.p12";
+ private static final String PASSWORD_FILE = "http-transport-password";
+ private static final String INITIALIZING_FILE = "http-transport-initializing";
+ private static final String ENROLLMENT_STATE_FILE = "http-transport-clients.properties";
+ private static final String OUTGOING_DIRECTORY = "outgoing-v1";
+ private static final char[] EMPTY_PASSWORD = new char[0];
+ static final Duration RENEW_BEFORE = Duration.ofDays(30);
+ static final Duration CA_RENEW_BEFORE = Duration.ofDays(365);
+ private final PrivateKey caKey;
+ private volatile X509Certificate caCertificate;
+ private volatile PrivateKey serverKey;
+ private volatile X509Certificate serverCertificate;
+ private final char[] password;
+ private final Path caFile;
+ private final Path serverFile;
+ private final String advertisedHost;
+
+ private HttpTlsIdentity(PrivateKey caKey, X509Certificate caCertificate, PrivateKey serverKey,
+ X509Certificate serverCertificate, char[] password, Path caFile, Path serverFile, String advertisedHost) {
+ this.caKey = caKey;
+ this.caCertificate = caCertificate;
+ this.serverKey = serverKey;
+ this.serverCertificate = serverCertificate;
+ this.password = password.clone();
+ this.caFile = caFile;
+ this.serverFile = serverFile;
+ this.advertisedHost = advertisedHost;
+ }
+
+ public static HttpTlsIdentity loadOrCreate(Path directory, String advertisedHost) throws Exception {
+ return loadOrCreate(directory, advertisedHost, Clock.systemUTC());
+ }
+
+ static HttpTlsIdentity loadOrCreate(Path directory, String advertisedHost, Clock clock) throws Exception {
+ if (directory == null) throw new IllegalArgumentException("Identity directory is required");
+ if (advertisedHost == null || advertisedHost.isBlank() || advertisedHost.length() > 253)
+ throw new IllegalArgumentException("Advertised HTTPS host is invalid");
+ if (clock == null) throw new IllegalArgumentException("Clock is required");
+ Path identityDirectory = directory.toAbsolutePath().normalize();
+ if (Files.isSymbolicLink(identityDirectory)) throw new IOException("HTTP TLS identity directory is unsafe");
+ boolean created = !Files.exists(identityDirectory, LinkOption.NOFOLLOW_LINKS);
+ Files.createDirectories(identityDirectory);
+ if (Files.isSymbolicLink(identityDirectory) || !Files.isDirectory(identityDirectory, LinkOption.NOFOLLOW_LINKS))
+ throw new IOException("HTTP TLS identity directory is unsafe");
+ // The identity files cannot make the newly created directory entry durable.
+ // Persist its parent before the TLS identity is returned for listener use.
+ if (created) DurableFiles.forceDirectory(identityDirectory.getParent());
+ directory = identityDirectory;
+ Path caFile = safe(directory.resolve(CA_FILE));
+ Path serverFile = safe(directory.resolve(SERVER_FILE));
+ Path passwordFile = safe(directory.resolve(PASSWORD_FILE));
+ Path initializingFile = safe(directory.resolve(INITIALIZING_FILE));
+ boolean caExists = Files.exists(caFile, LinkOption.NOFOLLOW_LINKS);
+ boolean serverExists = Files.exists(serverFile, LinkOption.NOFOLLOW_LINKS);
+ boolean passwordExists = Files.exists(passwordFile, LinkOption.NOFOLLOW_LINKS);
+ boolean initializing = Files.exists(initializingFile, LinkOption.NOFOLLOW_LINKS);
+ boolean anyIdentityFile = caExists || serverExists || passwordExists;
+ boolean completeIdentity = caExists && serverExists && passwordExists;
+ boolean persistentTransportState = hasPersistentTransportState(directory);
+ if (anyIdentityFile && !completeIdentity && !initializing)
+ throw new IOException("HTTP TLS identity files are incomplete");
+ if (initializing) {
+ if (persistentTransportState)
+ throw new IOException("HTTP TLS identity files are incomplete");
+ discardUncommittedIdentity(caFile, serverFile, passwordFile);
+ caExists = false;
+ serverExists = false;
+ passwordExists = false;
+ initializing = true;
+ }
+ if (caExists || serverExists || passwordExists) {
+ if (!completeIdentity) throw new IOException("HTTP TLS identity files are incomplete");
+ char[] password = readPassword(passwordFile);
+ try {
+ KeyStore ca = load(caFile, password);
+ KeyStore server = load(serverFile, password);
+ PrivateKey caKey = (PrivateKey) ca.getKey("ca", password);
+ X509Certificate caCertificate = (X509Certificate) ca.getCertificate("ca");
+ PrivateKey serverKey = (PrivateKey) server.getKey("server", password);
+ X509Certificate serverCertificate = (X509Certificate) server.getCertificate("server");
+ if (caKey == null || caCertificate == null || serverKey == null || serverCertificate == null)
+ throw new IOException("HTTP TLS identity files are invalid");
+ boolean caRenewed = needsCaRenewal(caCertificate, clock);
+ if (caRenewed) {
+ ensureBouncyCastle();
+ KeyPair caPair = new KeyPair(caCertificate.getPublicKey(), caKey);
+ caCertificate = certificate("CN=VotingPlugin HTTP private CA", caPair, null, null,
+ CertificateRole.CA, null, clock.instant());
+ ca = KeyStore.getInstance("PKCS12");
+ ca.load(null, EMPTY_PASSWORD);
+ ca.setKeyEntry("ca", caKey, password, new Certificate[] { caCertificate });
+ writeStore(caFile, ca, password);
+ }
+ if (caRenewed || !hasServerName(serverCertificate, advertisedHost) || needsRenewal(serverCertificate, clock)) {
+ ensureBouncyCastle();
+ KeyPair serverPair = keyPair();
+ serverCertificate = certificate("CN=" + certificateName(advertisedHost), serverPair, caCertificate, caKey,
+ CertificateRole.SERVER, advertisedHost, clock.instant());
+ serverKey = serverPair.getPrivate();
+ server = KeyStore.getInstance("PKCS12");
+ server.load(null, EMPTY_PASSWORD);
+ server.setKeyEntry("server", serverKey, password, new Certificate[] { serverCertificate, caCertificate });
+ writeStore(serverFile, server, password);
+ }
+ return new HttpTlsIdentity(caKey, caCertificate, serverKey, serverCertificate, password, caFile, serverFile,
+ advertisedHost);
+ } finally { Arrays.fill(password, '\0'); }
+ }
+ if (persistentTransportState)
+ throw new IOException("HTTP TLS identity files are missing");
+ if (!initializing) writeInitializationMarker(initializingFile);
+ ensureBouncyCastle();
+ char[] password = HttpTransportSecrets.randomToken().toCharArray();
+ try {
+ KeyPair caPair = keyPair();
+ X509Certificate caCertificate = certificate("CN=VotingPlugin HTTP private CA", caPair, null, null, CertificateRole.CA, null,
+ clock.instant());
+ KeyPair serverPair = keyPair();
+ X509Certificate serverCertificate = certificate("CN=" + certificateName(advertisedHost), serverPair, caCertificate,
+ caPair.getPrivate(), CertificateRole.SERVER, advertisedHost, clock.instant());
+ KeyStore ca = KeyStore.getInstance("PKCS12");
+ ca.load(null, EMPTY_PASSWORD);
+ ca.setKeyEntry("ca", caPair.getPrivate(), password, new Certificate[] { caCertificate });
+ KeyStore server = KeyStore.getInstance("PKCS12");
+ server.load(null, EMPTY_PASSWORD);
+ server.setKeyEntry("server", serverPair.getPrivate(), password, new Certificate[] { serverCertificate, caCertificate });
+ writeStore(caFile, ca, password);
+ writeStore(serverFile, server, password);
+ byte[] passwordBytes = asciiBytes(password);
+ try { writePrivate(passwordFile, passwordBytes); }
+ finally { Arrays.fill(passwordBytes, (byte) 0); }
+ DurableFiles.deleteIfExists(initializingFile);
+ return new HttpTlsIdentity(caPair.getPrivate(), caCertificate, serverPair.getPrivate(), serverCertificate, password,
+ caFile, serverFile, advertisedHost);
+ } finally { Arrays.fill(password, '\0'); }
+ }
+
+ public String serverCertificatePin() {
+ refreshIdentity();
+ return HttpTransportSecrets.certificatePin(serverCertificate);
+ }
+ public String caCertificatePin() { refreshIdentity(); return HttpTransportSecrets.certificatePin(caCertificate); }
+ public X509Certificate caCertificate() { return caCertificate; }
+ public X509Certificate serverCertificate() { return serverCertificate; }
+
+ /**
+ * The listener requests an optional client certificate so enrollment can share the same port.
+ * Any certificate that is presented must chain to this transport's private CA; normal requests
+ * additionally validate the certificate's persisted backend binding in the HTTP handler.
+ */
+ public SSLContext serverContext() throws Exception {
+ renewIdentityIfNeeded();
+ SSLContext context = SSLContext.getInstance("TLS");
+ context.init(new KeyManager[] { new RotatingServerKeyManager() }, trustManagers(caCertificate), null);
+ return context;
+ }
+
+ static TrustManager[] trustManagers(X509Certificate caCertificate) throws Exception {
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, EMPTY_PASSWORD);
+ trustStore.setCertificateEntry("http-transport-ca", caCertificate);
+ TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ factory.init(trustStore);
+ return factory.getTrustManagers();
+ }
+
+ private void refreshIdentity() {
+ try { renewIdentityIfNeeded(); }
+ catch (Exception failure) { throw new IllegalStateException("Could not renew HTTP TLS identity", failure); }
+ }
+
+ private synchronized void renewIdentityIfNeeded() throws Exception {
+ Clock clock = Clock.systemUTC();
+ boolean renewCa = needsCaRenewal(caCertificate, clock);
+ if (!renewCa && !needsRenewal(serverCertificate, clock)) return;
+ ensureBouncyCastle();
+ X509Certificate replacementCa = caCertificate;
+ if (renewCa) {
+ KeyPair caPair = new KeyPair(caCertificate.getPublicKey(), caKey);
+ replacementCa = certificate("CN=VotingPlugin HTTP private CA", caPair, null, null,
+ CertificateRole.CA, null, clock.instant());
+ KeyStore caStore = KeyStore.getInstance("PKCS12");
+ caStore.load(null, EMPTY_PASSWORD);
+ caStore.setKeyEntry("ca", caKey, password, new Certificate[] { replacementCa });
+ writeStore(caFile, caStore, password);
+ }
+ KeyPair pair = keyPair();
+ X509Certificate replacement = certificate("CN=" + certificateName(advertisedHost), pair, replacementCa, caKey,
+ CertificateRole.SERVER, advertisedHost, clock.instant());
+ KeyStore store = KeyStore.getInstance("PKCS12");
+ store.load(null, EMPTY_PASSWORD);
+ store.setKeyEntry("server", pair.getPrivate(), password, new Certificate[] { replacement, replacementCa });
+ writeStore(serverFile, store, password);
+ caCertificate = replacementCa;
+ serverKey = pair.getPrivate();
+ serverCertificate = replacement;
+ }
+
+ public IssuedClientCertificate issueClientCertificate(String serverId) throws Exception {
+ return issueClientCertificate(serverId, Instant.now());
+ }
+
+ IssuedClientCertificate issueClientCertificate(String serverId, Instant issuedAt) throws Exception {
+ serverId = canonicalServerId(serverId);
+ if (issuedAt == null) throw new IllegalArgumentException("Certificate issuance time is required");
+ ensureBouncyCastle();
+ KeyPair pair = keyPair();
+ X509Certificate certificate = certificate("CN=" + serverId, pair, caCertificate, caKey, CertificateRole.CLIENT,
+ "urn:votingplugin:http-backend:" + serverId, issuedAt);
+ char[] clientPassword = HttpTransportSecrets.randomToken().toCharArray();
+ try {
+ KeyStore store = KeyStore.getInstance("PKCS12");
+ store.load(null, EMPTY_PASSWORD);
+ store.setKeyEntry("client", pair.getPrivate(), clientPassword, new Certificate[] { certificate, caCertificate });
+ java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
+ store.store(bytes, clientPassword);
+ return new IssuedClientCertificate(serverId, certificate, bytes.toByteArray(), clientPassword);
+ } finally { Arrays.fill(clientPassword, '\0'); }
+ }
+
+ public static String canonicalServerId(String serverId) {
+ if (serverId == null || !serverId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"))
+ throw new IllegalArgumentException("Server id is invalid");
+ return serverId.toLowerCase(java.util.Locale.ROOT);
+ }
+
+ public boolean issuedByThisCa(X509Certificate certificate) {
+ if (certificate == null) return false;
+ try {
+ certificate.checkValidity();
+ certificate.verify(caCertificate.getPublicKey());
+ return true;
+ } catch (Exception failure) {
+ return false;
+ }
+ }
+
+ public boolean validClientCertificate(String expectedServerId, X509Certificate certificate) {
+ if (!issuedByThisCa(certificate)) return false;
+ try {
+ List usage = certificate.getExtendedKeyUsage();
+ boolean[] keyUsage = certificate.getKeyUsage();
+ if (usage == null || !usage.contains(KeyPurposeId.id_kp_clientAuth.getId()) || keyUsage == null || !keyUsage[0]) return false;
+ String expectedUri = "urn:votingplugin:http-backend:" + canonicalServerId(expectedServerId);
+ Collection> names = certificate.getSubjectAlternativeNames();
+ if (names == null) return false;
+ for (List> name : names) {
+ if (name.size() == 2 && Integer.valueOf(GeneralName.uniformResourceIdentifier).equals(name.get(0))
+ && expectedUri.equals(name.get(1))) return true;
+ }
+ return false;
+ } catch (Exception failure) { return false; }
+ }
+
+ public record IssuedClientCertificate(String serverId, X509Certificate certificate, byte[] pkcs12, char[] password) {
+ public IssuedClientCertificate {
+ pkcs12 = pkcs12.clone();
+ password = password.clone();
+ }
+ @Override public byte[] pkcs12() { return pkcs12.clone(); }
+ @Override public char[] password() { return password.clone(); }
+ }
+
+ private static KeyPair keyPair() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(new java.security.spec.ECGenParameterSpec("secp256r1"));
+ return generator.generateKeyPair();
+ }
+
+ private static X509Certificate certificate(String subject, KeyPair subjectKey, X509Certificate issuer, PrivateKey issuerKey,
+ CertificateRole role, String subjectAlternativeName, Instant now) throws Exception {
+ X500Name issuerName = issuer == null ? new X500Name(subject) : new X500Name(issuer.getSubjectX500Principal().getName());
+ X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(issuerName,
+ new BigInteger(160, new java.security.SecureRandom()).setBit(159), Date.from(now.minusSeconds(300)),
+ Date.from(now.plusSeconds(role == CertificateRole.CA ? 315360000L : 31536000L)), new X500Name(subject), subjectKey.getPublic());
+ builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(role == CertificateRole.CA));
+ builder.addExtension(Extension.keyUsage, true, new KeyUsage(role == CertificateRole.CA ? KeyUsage.keyCertSign | KeyUsage.cRLSign
+ : KeyUsage.digitalSignature));
+ if (role == CertificateRole.SERVER) builder.addExtension(Extension.extendedKeyUsage, false,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth));
+ if (role == CertificateRole.CLIENT) builder.addExtension(Extension.extendedKeyUsage, false,
+ new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth));
+ if (role == CertificateRole.SERVER && subjectAlternativeName != null) {
+ GeneralName name;
+ if (subjectAlternativeName.matches("(?:\\d{1,3}\\.){3}\\d{1,3}") || subjectAlternativeName.indexOf(':') >= 0)
+ name = new GeneralName(GeneralName.iPAddress, subjectAlternativeName);
+ else name = new GeneralName(GeneralName.dNSName, subjectAlternativeName);
+ builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(name));
+ }
+ if (role == CertificateRole.CLIENT) builder.addExtension(Extension.subjectAlternativeName, false,
+ new GeneralNames(new GeneralName(GeneralName.uniformResourceIdentifier, subjectAlternativeName)));
+ ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").setProvider("BC")
+ .build(issuerKey == null ? subjectKey.getPrivate() : issuerKey);
+ X509CertificateHolder holder = builder.build(signer);
+ return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);
+ }
+
+ static boolean needsRenewal(X509Certificate certificate, Clock clock) {
+ return certificate == null || !certificate.getNotAfter().toInstant().isAfter(clock.instant().plus(RENEW_BEFORE));
+ }
+
+ static boolean needsCaRenewal(X509Certificate certificate, Clock clock) {
+ return certificate == null || !certificate.getNotAfter().toInstant().isAfter(clock.instant().plus(CA_RENEW_BEFORE));
+ }
+
+ private static void ensureBouncyCastle() {
+ if (Security.getProvider("BC") == null) Security.addProvider(new BouncyCastleProvider());
+ }
+
+ private static String certificateName(String host) {
+ return host.replaceAll("[^A-Za-z0-9 ._-]", "_");
+ }
+
+ private static boolean hasServerName(X509Certificate certificate, String advertisedHost) {
+ try {
+ Collection> names = certificate.getSubjectAlternativeNames();
+ if (names == null) return false;
+ for (List> name : names) {
+ if (name.size() != 2 || !(name.get(1) instanceof String value)) continue;
+ if ((Integer.valueOf(GeneralName.dNSName).equals(name.get(0)) || Integer.valueOf(GeneralName.iPAddress).equals(name.get(0)))
+ && advertisedHost.equalsIgnoreCase(value)) return true;
+ }
+ return false;
+ } catch (Exception failure) { return false; }
+ }
+
+ private static Path safe(Path file) throws IOException {
+ Path parent = file.toAbsolutePath().normalize().getParent();
+ if (parent == null || Files.isSymbolicLink(file)) throw new IOException("Refusing unsafe HTTP TLS identity path");
+ return file.toAbsolutePath().normalize();
+ }
+
+ private static KeyStore load(Path path, char[] password) throws Exception {
+ KeyStore store = KeyStore.getInstance("PKCS12");
+ try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) { store.load(input, password); }
+ return store;
+ }
+
+ private static char[] readPassword(Path path) throws IOException {
+ byte[] bytes = Files.readAllBytes(path);
+ if (bytes.length < 40 || bytes.length > 128) throw new IOException("HTTP TLS password file is invalid");
+ try { return new String(bytes, java.nio.charset.StandardCharsets.US_ASCII).toCharArray(); }
+ finally { Arrays.fill(bytes, (byte) 0); }
+ }
+
+ private static void writeStore(Path file, KeyStore store, char[] password) throws Exception {
+ java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
+ store.store(bytes, password);
+ byte[] contents = bytes.toByteArray();
+ try { writePrivate(file, contents); }
+ finally { Arrays.fill(contents, (byte) 0); }
+ }
+
+ private static void writePrivate(Path file, byte[] contents) throws IOException {
+ Path temporary = Files.createTempFile(file.getParent(), file.getFileName().toString(), ".tmp");
+ try {
+ setOwnerOnly(temporary);
+ Files.write(temporary, contents, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
+ DurableFiles.forceFile(temporary);
+ try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
+ catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); }
+ setOwnerOnly(file);
+ DurableFiles.forceDirectory(file.getParent());
+ } finally { Files.deleteIfExists(temporary); }
+ }
+
+ private static void writeInitializationMarker(Path file) throws IOException {
+ writePrivate(file, "initializing\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
+ }
+
+ private static void discardUncommittedIdentity(Path caFile, Path serverFile, Path passwordFile) throws IOException {
+ DurableFiles.deleteIfExists(caFile);
+ DurableFiles.deleteIfExists(serverFile);
+ DurableFiles.deleteIfExists(passwordFile);
+ }
+
+ private static boolean hasPersistentTransportState(Path directory) {
+ return Files.exists(directory.resolve(ENROLLMENT_STATE_FILE), LinkOption.NOFOLLOW_LINKS)
+ || Files.exists(directory.resolve(OUTGOING_DIRECTORY), LinkOption.NOFOLLOW_LINKS);
+ }
+
+ private static byte[] asciiBytes(char[] characters) {
+ byte[] output = new byte[characters.length];
+ for (int index = 0; index < characters.length; index++) output[index] = (byte) characters[index];
+ return output;
+ }
+
+ private static void setOwnerOnly(Path path) throws IOException {
+ try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); }
+ catch (UnsupportedOperationException ignored) { /* Windows ACLs are inherited; never make the file world-readable. */ }
+ }
+
+ private final class RotatingServerKeyManager extends X509ExtendedKeyManager {
+ private static final String ALIAS = "server";
+ private void refresh() {
+ refreshIdentity();
+ }
+ private String alias(String keyType) {
+ refresh();
+ return keyType != null && ("EC".equalsIgnoreCase(keyType) || keyType.toUpperCase(java.util.Locale.ROOT).startsWith("EC_"))
+ ? ALIAS : null;
+ }
+ @Override public String[] getClientAliases(String keyType, Principal[] issuers) { return null; }
+ @Override public String chooseClientAlias(String[] keyTypes, Principal[] issuers, Socket socket) { return null; }
+ @Override public String[] getServerAliases(String keyType, Principal[] issuers) {
+ return alias(keyType) == null ? null : new String[] { ALIAS };
+ }
+ @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { return alias(keyType); }
+ @Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { return alias(keyType); }
+ @Override public X509Certificate[] getCertificateChain(String alias) {
+ refresh();
+ return ALIAS.equals(alias) ? new X509Certificate[] { serverCertificate, caCertificate } : null;
+ }
+ @Override public PrivateKey getPrivateKey(String alias) { refresh(); return ALIAS.equals(alias) ? serverKey : null; }
+ }
+
+ private enum CertificateRole { CA, SERVER, CLIENT }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTransportProtocol.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTransportProtocol.java
new file mode 100644
index 0000000..e1aae28
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTransportProtocol.java
@@ -0,0 +1,234 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
+import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.List;
+import java.util.UUID;
+
+/** Strict, versioned HTTP transport envelope. Payloads remain canonical JsonEnvelopeCodec values. */
+final class HttpTransportProtocol {
+ static final int VERSION = 1;
+ static final int MAX_BODY_BYTES = 256 * 1024;
+ static final int MAX_BATCH = 64;
+ static final int MAX_ENVELOPE_BYTES = 48 * 1024;
+ static final int MAX_QUEUE = 1024;
+ static final long MAX_CLOCK_SKEW_MILLIS = 90_000L;
+
+ private HttpTransportProtocol() { }
+
+ static void validateEnvelope(JsonEnvelope envelope) {
+ if (envelope == null || JsonEnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8).length > MAX_ENVELOPE_BYTES) throw bad();
+ }
+
+ static byte[] request(String server, String session, long sequence, Collection acks,
+ Collection ackConfirmations, Collection messages) {
+ JsonObject root = base(server, session, sequence);
+ root.add("acks", ids(acks));
+ root.add("ackConfirmations", ids(ackConfirmations));
+ root.add("messages", messages(messages));
+ byte[] encoded = root.toString().getBytes(StandardCharsets.UTF_8);
+ if (encoded.length > MAX_BODY_BYTES) throw bad();
+ return encoded;
+ }
+
+ static List fittingMessages(String server, String session, long sequence, Collection acks,
+ Collection ackConfirmations, Collection candidates) {
+ List output = new ArrayList<>();
+ for (Delivery candidate : candidates) {
+ if (output.size() == MAX_BATCH) break;
+ output.add(candidate);
+ try { request(server, session, sequence, acks, ackConfirmations, output); }
+ catch (IllegalArgumentException tooLarge) { output.remove(output.size() - 1); break; }
+ }
+ return output;
+ }
+
+ static byte[] storedDelivery(Delivery delivery) {
+ validId(delivery.id());
+ validateEnvelope(delivery.envelope());
+ JsonObject root = new JsonObject();
+ root.addProperty("v", VERSION);
+ root.addProperty("id", delivery.id());
+ root.addProperty("payload", Base64.getUrlEncoder().withoutPadding().encodeToString(
+ JsonEnvelopeCodec.encode(delivery.envelope()).getBytes(StandardCharsets.UTF_8)));
+ return root.toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ static Delivery parseStoredDelivery(byte[] body) {
+ if (body == null || body.length == 0 || body.length > MAX_ENVELOPE_BYTES * 2) throw bad();
+ try {
+ JsonObject root = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)).getAsJsonObject();
+ requireOnly(root, "v", "id", "payload");
+ if (integer(root, "v") != VERSION) throw bad();
+ String id = string(root, "id", 64); validId(id);
+ byte[] payload = Base64.getUrlDecoder().decode(string(root, "payload", MAX_ENVELOPE_BYTES * 2));
+ if (payload.length == 0 || payload.length > MAX_ENVELOPE_BYTES) throw bad();
+ return new Delivery(id, JsonEnvelopeCodec.decode(new String(payload, StandardCharsets.UTF_8)));
+ } catch (RuntimeException invalid) { throw bad(); }
+ }
+
+ static byte[] response(String server, String session, long sequence, Collection acks,
+ Collection ackConfirmations, Collection messages) {
+ return request(server, session, sequence, acks, ackConfirmations, messages);
+ }
+
+ static Packet parsePacket(byte[] body) {
+ if (body == null || body.length == 0 || body.length > MAX_BODY_BYTES) throw bad();
+ try {
+ JsonElement parsed = JsonParser.parseString(new String(body, StandardCharsets.UTF_8));
+ if (!parsed.isJsonObject()) throw bad();
+ JsonObject root = parsed.getAsJsonObject();
+ requireOnly(root, "v", "server", "session", "sequence", "timestamp", "acks", "ackConfirmations", "messages");
+ if (integer(root, "v") != VERSION) throw bad();
+ String server = HttpTlsIdentity.canonicalServerId(string(root, "server", 64));
+ String session = uuid(root, "session");
+ long sequence = nonNegative(root, "sequence");
+ long timestamp = integer(root, "timestamp");
+ long now = Instant.now().toEpochMilli();
+ if (timestamp < now - MAX_CLOCK_SKEW_MILLIS || timestamp > now + MAX_CLOCK_SKEW_MILLIS) throw bad();
+ List acks = parseIds(root.get("acks"));
+ List ackConfirmations = parseIds(root.get("ackConfirmations"));
+ List messages = parseMessages(root.get("messages"));
+ return new Packet(server, session, sequence, acks, ackConfirmations, messages);
+ } catch (RuntimeException invalid) { throw bad(); }
+ }
+
+ static byte[] enrollmentResponse(HttpTlsIdentity.IssuedClientCertificate certificate) {
+ JsonObject output = new JsonObject();
+ byte[] bundle = certificate.pkcs12();
+ try { output.addProperty("bundle", Base64.getUrlEncoder().withoutPadding().encodeToString(bundle)); }
+ finally { java.util.Arrays.fill(bundle, (byte) 0); }
+ char[] password = certificate.password();
+ try { output.addProperty("password", new String(password)); }
+ finally { java.util.Arrays.fill(password, '\0'); }
+ return output.toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ static Enrollment parseEnrollment(byte[] body) {
+ if (body == null || body.length == 0 || body.length > 8192) throw bad();
+ try {
+ JsonElement parsed = JsonParser.parseString(new String(body, StandardCharsets.UTF_8));
+ if (!parsed.isJsonObject()) throw bad();
+ JsonObject root = parsed.getAsJsonObject();
+ requireOnly(root, "server", "token");
+ String server = HttpTlsIdentity.canonicalServerId(string(root, "server", 64));
+ String token = string(root, "token", 128);
+ if (!token.matches("[A-Za-z0-9_-]{43,128}")) throw bad();
+ return new Enrollment(server, token);
+ } catch (RuntimeException invalid) { throw bad(); }
+ }
+
+ static byte[] renewalRequest(String server) {
+ JsonObject root = new JsonObject();
+ root.addProperty("server", HttpTlsIdentity.canonicalServerId(server));
+ return root.toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ static String parseRenewal(byte[] body) {
+ if (body == null || body.length == 0 || body.length > 1024) throw bad();
+ try {
+ JsonElement parsed = JsonParser.parseString(new String(body, StandardCharsets.UTF_8));
+ if (!parsed.isJsonObject()) throw bad();
+ JsonObject root = parsed.getAsJsonObject();
+ requireOnly(root, "server");
+ return HttpTlsIdentity.canonicalServerId(string(root, "server", 64));
+ } catch (RuntimeException invalid) { throw bad(); }
+ }
+
+ static HttpTlsIdentity.IssuedClientCertificate parseEnrollmentResponse(String server, byte[] body) {
+ if (body == null || body.length == 0 || body.length > MAX_BODY_BYTES) throw bad();
+ try {
+ JsonObject root = JsonParser.parseString(new String(body, StandardCharsets.UTF_8)).getAsJsonObject();
+ requireOnly(root, "bundle", "password");
+ byte[] bundle = Base64.getUrlDecoder().decode(string(root, "bundle", MAX_BODY_BYTES * 2));
+ char[] password = string(root, "password", 128).toCharArray();
+ if (bundle.length == 0 || password.length < 40) throw bad();
+ try { return new HttpTlsIdentity.IssuedClientCertificate(HttpTlsIdentity.canonicalServerId(server), null, bundle, password); }
+ finally { java.util.Arrays.fill(bundle, (byte) 0); java.util.Arrays.fill(password, '\0'); }
+ } catch (RuntimeException invalid) { throw bad(); }
+ }
+
+ private static JsonObject base(String server, String session, long sequence) {
+ JsonObject root = new JsonObject();
+ root.addProperty("v", VERSION); root.addProperty("server", server); root.addProperty("session", session);
+ root.addProperty("sequence", sequence); root.addProperty("timestamp", Instant.now().toEpochMilli());
+ return root;
+ }
+ private static JsonArray ids(Collection values) {
+ if (values == null || values.size() > MAX_BATCH) throw bad();
+ JsonArray output = new JsonArray();
+ for (String value : values) { validId(value); output.add(value); }
+ return output;
+ }
+ private static JsonArray messages(Collection values) {
+ if (values == null || values.size() > MAX_BATCH) throw bad();
+ JsonArray output = new JsonArray();
+ for (Delivery delivery : values) {
+ validId(delivery.id());
+ String encoded = JsonEnvelopeCodec.encode(delivery.envelope());
+ byte[] bytes = encoded.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length > MAX_ENVELOPE_BYTES) throw bad();
+ JsonObject item = new JsonObject(); item.addProperty("id", delivery.id());
+ item.addProperty("payload", Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)); output.add(item);
+ }
+ return output;
+ }
+ private static List parseIds(JsonElement value) {
+ if (value == null || !value.isJsonArray() || value.getAsJsonArray().size() > MAX_BATCH) throw bad();
+ List output = new ArrayList<>();
+ for (JsonElement item : value.getAsJsonArray()) { if (!item.isJsonPrimitive()) throw bad(); String id = item.getAsString(); validId(id); output.add(id); }
+ return output;
+ }
+ private static List parseMessages(JsonElement value) {
+ if (value == null || !value.isJsonArray() || value.getAsJsonArray().size() > MAX_BATCH) throw bad();
+ List output = new ArrayList<>();
+ for (JsonElement item : value.getAsJsonArray()) {
+ if (!item.isJsonObject()) throw bad(); JsonObject object = item.getAsJsonObject(); requireOnly(object, "id", "payload");
+ String id = string(object, "id", 64); validId(id);
+ byte[] payload = Base64.getUrlDecoder().decode(string(object, "payload", MAX_ENVELOPE_BYTES * 2));
+ if (payload.length == 0 || payload.length > MAX_ENVELOPE_BYTES) throw bad();
+ JsonEnvelope envelope = JsonEnvelopeCodec.decode(new String(payload, StandardCharsets.UTF_8));
+ output.add(new Delivery(id, envelope));
+ }
+ return output;
+ }
+ private static void requireOnly(JsonObject object, String... names) {
+ for (String name : object.keySet()) { boolean found = false; for (String allowed : names) if (allowed.equals(name)) { found = true; break; } if (!found) throw bad(); }
+ for (String name : names) if (!object.has(name) || object.get(name).isJsonNull()) throw bad();
+ }
+ private static String string(JsonObject object, String name, int max) { JsonElement v = object.get(name); if (!v.isJsonPrimitive() || !v.getAsJsonPrimitive().isString()) throw bad(); String value = v.getAsString(); if (value.isEmpty() || value.length() > max) throw bad(); return value; }
+ private static long integer(JsonObject object, String name) {
+ try {
+ JsonElement value = object.get(name);
+ if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) throw bad();
+ String token = value.getAsString();
+ if (!token.matches("-?(?:0|[1-9][0-9]*)")) throw bad();
+ return Long.parseLong(token);
+ } catch (RuntimeException failure) { throw bad(); }
+ }
+ private static long nonNegative(JsonObject object, String name) { long n = integer(object, name); if (n < 0) throw bad(); return n; }
+ private static String uuid(JsonObject object, String name) { return canonicalUuid(string(object, name, 64)); }
+ static void validId(String id) { if (id == null || id.length() > 64) throw bad(); canonicalUuid(id); }
+ private static String canonicalUuid(String value) {
+ try {
+ UUID parsed = UUID.fromString(value);
+ if (!parsed.toString().equals(value)) throw bad();
+ return value;
+ } catch (IllegalArgumentException invalid) { throw bad(); }
+ }
+ private static IllegalArgumentException bad() { return new IllegalArgumentException("Invalid HTTP transport message"); }
+
+ record Delivery(String id, JsonEnvelope envelope) { }
+ record Packet(String server, String session, long sequence, List acks,
+ List ackConfirmations, List messages) { }
+ record Enrollment(String server, String token) { }
+}
diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTransportSecrets.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTransportSecrets.java
new file mode 100644
index 0000000..efd0b3b
--- /dev/null
+++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpTransportSecrets.java
@@ -0,0 +1,64 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.Base64;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/** Small, deliberately dependency-free cryptographic helpers for the HTTP transport. */
+final class HttpTransportSecrets {
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private HttpTransportSecrets() { }
+
+ static byte[] randomBytes(int length) {
+ if (length < 16) throw new IllegalArgumentException("Secret length is too small");
+ byte[] value = new byte[length];
+ RANDOM.nextBytes(value);
+ return value;
+ }
+
+ static String randomToken() {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes(32));
+ }
+
+ static byte[] sha256(byte[] value) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(value);
+ } catch (NoSuchAlgorithmException impossible) {
+ throw new IllegalStateException("SHA-256 is unavailable", impossible);
+ }
+ }
+
+ static String sha256Hex(byte[] value) {
+ StringBuilder output = new StringBuilder(64);
+ for (byte part : sha256(value)) output.append(String.format("%02x", part & 0xff));
+ return output.toString();
+ }
+
+ static String certificatePin(X509Certificate certificate) {
+ try {
+ return sha256Hex(certificate.getEncoded());
+ } catch (Exception failure) {
+ throw new IllegalArgumentException("Could not encode certificate", failure);
+ }
+ }
+
+ static boolean constantTimeEquals(byte[] first, byte[] second) {
+ return first != null && second != null && MessageDigest.isEqual(first, second);
+ }
+
+ static String hmacSha256Url(byte[] key, String value) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(value.getBytes(StandardCharsets.US_ASCII)));
+ } catch (Exception failure) {
+ throw new IllegalStateException("HMAC-SHA-256 is unavailable", failure);
+ }
+ }
+}
diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/HttpTransportRuntimeTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/HttpTransportRuntimeTest.java
new file mode 100644
index 0000000..0f6c816
--- /dev/null
+++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/HttpTransportRuntimeTest.java
@@ -0,0 +1,888 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class HttpTransportRuntimeTest {
+ @TempDir Path directory;
+
+ @Test
+ void endpointHelperSupportsIpv6Literals() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("ipv6-proxy"), "::1");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("ipv6-authority"));
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0),
+ identity, authority, directory.resolve("ipv6-outgoing"), ignored -> { })) {
+ URI endpoint = server.endpoint("::1");
+ assertTrue(endpoint.getHost() != null);
+ assertTrue(endpoint.toASCIIString().startsWith("https://[::1]:"));
+ assertDoesNotThrow(() -> new HttpConnectionCode("lobby-1", endpoint, identity.serverCertificatePin(),
+ identity.caCertificatePin(), Instant.now().plusSeconds(60), "A".repeat(43)));
+ }
+ }
+
+ @Test
+ void enrollsThenDeliversBothDirectionsWithAuthenticatedIdentity() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("authority"));
+ CountDownLatch proxyReceived = new CountDownLatch(1), backendReceived = new CountDownLatch(1);
+ AtomicReference received = new AtomicReference<>();
+ Path proxyOutgoing = directory.resolve("proxy-outgoing");
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, authority,
+ proxyOutgoing,
+ message -> { received.set(message); proxyReceived.countDown(); })) {
+ server.start();
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5));
+ HttpBackendTransportConnector.enroll(code, "lobby-1", directory.resolve("client"));
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(directory.resolve("client"),
+ envelope -> backendReceived.countDown())) {
+ connector.start();
+ assertTrue(connector.awaitFirstResponse(System.nanoTime() + TimeUnit.SECONDS.toNanos(8)),
+ "an authenticated transport response must make the connector ready");
+ assertTrue(connector.send(JsonEnvelope.builder("to-proxy").put("server", "forged").build()));
+ assertTrue(proxyReceived.await(8, TimeUnit.SECONDS));
+ assertEquals("lobby-1", received.get().serverId());
+ assertEquals("lobby-1", received.get().envelope().getFields().get("server"));
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
+ while (connector.queuedOutgoing() != 0 && System.nanoTime() < deadline) Thread.sleep(10);
+ assertEquals(0, connector.queuedOutgoing(), "proxy ACK must remove the exact outbound delivery ID");
+ Path proxyInboundFence = directory.resolve("proxy-outgoing-incoming");
+ long confirmationDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
+ while (countRegularFiles(proxyInboundFence) != 0L && System.nanoTime() < confirmationDeadline) Thread.sleep(10);
+ assertEquals(0L, countRegularFiles(proxyInboundFence),
+ "the backend must durably confirm receipt of the proxy ACK");
+ assertTrue(server.send("lobby-1", JsonEnvelope.builder("to-backend").build()));
+ assertTrue(backendReceived.await(8, TimeUnit.SECONDS));
+ long outgoingCleanupDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
+ while (Files.exists(proxyOutgoing.resolve("lobby-1")) && System.nanoTime() < outgoingCleanupDeadline)
+ Thread.sleep(10);
+ assertFalse(Files.exists(proxyOutgoing.resolve("lobby-1")),
+ "acknowledging the final delivery must remove its empty backend directory");
+ Path inboundFence = directory.resolve("client").resolve("http-transport-inbound-deliveries");
+ long fenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (countRegularFiles(inboundFence) != 0L && System.nanoTime() < fenceDeadline) Thread.sleep(20);
+ assertEquals(0L, countRegularFiles(inboundFence), "a confirmed ACK must remove the backend replay fence");
+ }
+ }
+ }
+
+ @Test
+ void responseBodyConsumptionRemainsBoundedByRequestTimeout() throws Exception {
+ com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create(
+ new InetSocketAddress("localhost", 0), 1);
+ CountDownLatch release = new CountDownLatch(1);
+ server.createContext("/stall", exchange -> {
+ exchange.sendResponseHeaders(200, 8);
+ try (var output = exchange.getResponseBody()) {
+ output.write(1);
+ output.flush();
+ try { release.await(5, TimeUnit.SECONDS); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
+ }
+ });
+ server.start();
+ try {
+ HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.getAddress().getPort()
+ + "/stall")).timeout(Duration.ofMillis(250)).GET().build();
+ assertTimeoutPreemptively(Duration.ofSeconds(2), () -> assertThrows(java.io.IOException.class,
+ () -> HttpBackendTransportConnector.sendLimited(HttpClient.newHttpClient(), request)));
+ } finally {
+ release.countDown();
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void stableProxyDeliveryIdsAreIdempotentAndAcknowledgedBeforeRemoval() throws Exception {
+ AtomicLong acknowledged = new AtomicLong();
+ HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState("lobby-1", null,
+ (server, deliveryId) -> acknowledged.incrementAndGet());
+ String deliveryId = java.util.UUID.randomUUID().toString();
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(deliveryId,
+ JsonEnvelope.builder("vote-party").build());
+ assertTrue(state.enqueue(delivery));
+ assertTrue(state.enqueue(delivery));
+ assertFalse(state.enqueue(new HttpTransportProtocol.Delivery(deliveryId,
+ JsonEnvelope.builder("different").build())));
+ state.acknowledge(java.util.List.of(deliveryId));
+ assertEquals(1L, acknowledged.get());
+ assertTrue(state.await("lobby-1", java.util.UUID.randomUUID().toString(), 0).messages().isEmpty());
+ }
+
+ @Test
+ void proxyInboundCompletionSurvivesRestartBeforeAcknowledgement() throws Exception {
+ Path root = directory.resolve("proxy-incoming");
+ Files.createDirectory(root);
+ String deliveryId = java.util.UUID.randomUUID().toString();
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(deliveryId,
+ JsonEnvelope.builder("backend-event").build());
+ HttpInboundDeliveryStore firstStore = HttpInboundDeliveryStore.open(root, "lobby-1");
+ HttpProxyTransportServer.BackendState first = new HttpProxyTransportServer.BackendState(
+ "lobby-1", null, firstStore, (server, id) -> { });
+ assertEquals(java.util.List.of(delivery), first.acceptIncoming(java.util.List.of(delivery)));
+ first.beginIncoming(deliveryId);
+ first.completeIncomingDurably(deliveryId);
+ first.completeIncoming(deliveryId, true);
+ firstStore.seal();
+
+ HttpInboundDeliveryStore restartedStore = HttpInboundDeliveryStore.open(root, "lobby-1");
+ HttpProxyTransportServer.BackendState restarted = new HttpProxyTransportServer.BackendState(
+ "lobby-1", null, restartedStore, (server, id) -> { });
+ assertTrue(restarted.acceptIncoming(java.util.List.of(delivery)).isEmpty(),
+ "a completed callback must not run again after a lost response and proxy restart");
+ HttpProxyTransportServer.Response response = restarted.await("lobby-1",
+ java.util.UUID.randomUUID().toString(), 0);
+ assertEquals(java.util.List.of(deliveryId), response.acks());
+ restarted.confirmIncoming(response.acks());
+ restartedStore.seal();
+ HttpInboundDeliveryStore confirmedStore = HttpInboundDeliveryStore.open(root, "lobby-1");
+ HttpProxyTransportServer.BackendState confirmed = new HttpProxyTransportServer.BackendState(
+ "lobby-1", null, confirmedStore, (server, id) -> { });
+ assertEquals(java.util.List.of(delivery), confirmed.acceptIncoming(java.util.List.of(delivery)),
+ "only an acknowledgement confirmation may retire the durable replay fence");
+ }
+
+ @Test
+ void failedAcknowledgementCallbackRetainsProxyDelivery() throws Exception {
+ HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState("lobby-1", null,
+ (server, deliveryId) -> { throw new java.io.IOException("cache save failed"); });
+ String deliveryId = java.util.UUID.randomUUID().toString();
+ assertTrue(state.enqueue(new HttpTransportProtocol.Delivery(deliveryId,
+ JsonEnvelope.builder("vote-party").build())));
+ assertThrows(java.io.IOException.class, () -> state.acknowledge(java.util.List.of(deliveryId)));
+ assertEquals(deliveryId, state.await("lobby-1", java.util.UUID.randomUUID().toString(), 0)
+ .messages().iterator().next().id());
+ }
+
+ @Test
+ void oppositeDirectionIdsUseSeparateAcknowledgementNamespaces() throws Exception {
+ String deliveryId = java.util.UUID.randomUUID().toString();
+ Path incomingRoot = directory.resolve("separate-ack-incoming");
+ Files.createDirectory(incomingRoot);
+ HttpInboundDeliveryStore incoming = HttpInboundDeliveryStore.open(incomingRoot, "lobby-1");
+ HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState(
+ "lobby-1", null, incoming, (server, id) -> { });
+ assertTrue(state.enqueue(new HttpTransportProtocol.Delivery(deliveryId,
+ JsonEnvelope.builder("proxy-reply").build())));
+ HttpTransportProtocol.Delivery backendMessage = new HttpTransportProtocol.Delivery(deliveryId,
+ JsonEnvelope.builder("backend-request").build());
+ assertEquals(java.util.List.of(backendMessage), state.acceptIncoming(java.util.List.of(backendMessage)));
+ state.beginIncoming(deliveryId);
+ state.completeIncomingDurably(deliveryId);
+ state.completeIncoming(deliveryId, true);
+
+ byte[] request = HttpTransportProtocol.request("lobby-1", java.util.UUID.randomUUID().toString(), 0,
+ java.util.List.of(), java.util.List.of(deliveryId), java.util.List.of());
+ HttpTransportProtocol.Packet packet = HttpTransportProtocol.parsePacket(request);
+ state.confirmIncoming(packet.ackConfirmations());
+ state.acknowledge(packet.acks());
+ assertEquals(deliveryId, state.await("lobby-1", java.util.UUID.randomUUID().toString(), 0)
+ .messages().iterator().next().id(),
+ "confirming the backend-origin acknowledgement must not acknowledge a same-ID proxy reply");
+ }
+
+ @Test
+ void closeWaitsForTheCredentialOwningPollerToStop() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("close-proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("close-authority"));
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0),
+ identity, authority, ignored -> { })) {
+ server.start();
+ Path clientDirectory = directory.resolve("close-client");
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"),
+ Duration.ofMinutes(5));
+ HttpBackendTransportConnector.enroll(code, "lobby-1", clientDirectory);
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(clientDirectory, ignored -> { })) {
+ connector.start();
+ assertTrue(connector.awaitFirstResponse(System.nanoTime() + TimeUnit.SECONDS.toNanos(8)));
+ connector.close();
+ assertFalse(connector.pollerAlive(),
+ "credential-directory ownership must outlive every poller filesystem mutation");
+ }
+ }
+ }
+
+ @Test
+ void finalFlushDeliversMessagesQueuedBehindAnActiveLongPoll() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("flush-proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("flush-authority"));
+ CountDownLatch received = new CountDownLatch(1);
+ AtomicReference envelope = new AtomicReference<>();
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0),
+ identity, authority, message -> {
+ envelope.set(message.envelope());
+ received.countDown();
+ })) {
+ server.start();
+ Path clientDirectory = directory.resolve("flush-client");
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"),
+ Duration.ofMinutes(5));
+ HttpBackendTransportConnector.enroll(code, "lobby-1", clientDirectory);
+ HttpBackendTransportConnector connector = new HttpBackendTransportConnector(clientDirectory, ignored -> { });
+ connector.start();
+ assertTrue(connector.awaitFirstResponse(System.nanoTime() + TimeUnit.SECONDS.toNanos(8)));
+ assertTrue(connector.send(JsonEnvelope.builder("backend-stopped").build()));
+ try {
+ assertTrue(connector.flushOutgoing(System.nanoTime() + TimeUnit.SECONDS.toNanos(5)));
+ assertTrue(received.await(1, TimeUnit.SECONDS));
+ assertEquals("backend-stopped", envelope.get().getSubChannel());
+ assertEquals(0, connector.queuedOutgoing());
+ } finally { connector.close(); }
+ }
+ }
+
+ @Test
+ void normalTransportRejectsAClientWithoutCertificate() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("authority"));
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, authority, ignored -> { })) {
+ server.start();
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5));
+ HttpClient client = HttpClient.newBuilder().sslContext(HttpPinnedTls.clientContext(code)).build();
+ byte[] body = HttpTransportProtocol.request("lobby-1", java.util.UUID.randomUUID().toString(), 0,
+ java.util.List.of(), java.util.List.of(), java.util.List.of());
+ HttpResponse response = client.send(HttpRequest.newBuilder(code.endpoint().resolve("v1/transport"))
+ .timeout(Duration.ofSeconds(5)).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(), HttpResponse.BodyHandlers.ofByteArray());
+ assertEquals(401, response.statusCode());
+ }
+ }
+
+ @Test
+ void boundedQueuesFailClosed() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("authority"));
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, authority, ignored -> { })) {
+ for (int i = 0; i < HttpTransportProtocol.MAX_QUEUE; i++) assertTrue(server.send("lobby-1", JsonEnvelope.builder("x").build()));
+ assertFalse(server.send("lobby-1", JsonEnvelope.builder("x").build()));
+ assertFalse(server.send("lobby-1", JsonEnvelope.builder("x").put("large", "x".repeat(HttpTransportProtocol.MAX_ENVELOPE_BYTES)).build()));
+ }
+ }
+
+ @Test
+ void proxyBackendStateIsGloballyBounded() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("bounded-backend-proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity,
+ directory.resolve("bounded-backend-authority"));
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0),
+ identity, authority, ignored -> { })) {
+ for (int index = 0; index < 128; index++)
+ assertTrue(server.send("server-" + index, JsonEnvelope.builder("x").build()));
+ assertFalse(server.send("server-overflow", JsonEnvelope.builder("x").build()));
+ assertTrue(server.send("server-0", JsonEnvelope.builder("existing").build()),
+ "the global bound must not reject an existing backend state");
+ }
+ }
+
+ @Test
+ void proxyReclaimsOnlyQuiescentBackendStateAfterReplayWindow() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("reclaim-proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity,
+ directory.resolve("reclaim-authority"));
+ AtomicLong nanoTime = new AtomicLong();
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0),
+ identity, authority, null, ignored -> { }, (serverId, deliveryId) -> { }, nanoTime::get)) {
+ for (int index = 0; index < 128; index++) {
+ HttpProxyTransportServer.BackendState state = server.backendStateForTest("server-" + index);
+ assertTrue(state.beginPollForTest());
+ state.endPollForTest();
+ }
+ assertFalse(server.send("replacement", JsonEnvelope.builder("x").build()),
+ "fresh state must retain its replay fence");
+ nanoTime.addAndGet(TimeUnit.MILLISECONDS.toNanos(HttpTransportProtocol.MAX_CLOCK_SKEW_MILLIS) + 1L);
+ assertTrue(server.send("replacement", JsonEnvelope.builder("x").build()),
+ "a quiescent state must be reclaimable after captured requests expire");
+ assertEquals(128, server.backendCountForTest());
+ }
+ }
+
+ @Test
+ void proxyOutgoingQueueSurvivesRestartUntilBackendAcknowledges() throws Exception {
+ Path proxyDirectory = directory.resolve("proxy");
+ Path authorityDirectory = directory.resolve("authority");
+ Path queueDirectory = directory.resolve("outgoing");
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(proxyDirectory, "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, authorityDirectory);
+ HttpProxyTransportServer first = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0),
+ identity, authority, queueDirectory, ignored -> { });
+ assertTrue(first.send("lobby-1", JsonEnvelope.builder("durable").build()));
+ first.close();
+
+ CountDownLatch received = new CountDownLatch(1);
+ try (HttpProxyTransportServer restarted = new HttpProxyTransportServer(
+ new InetSocketAddress("localhost", 0), identity, authority, queueDirectory, ignored -> { })) {
+ restarted.start();
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", restarted.endpoint("localhost"),
+ Duration.ofMinutes(5));
+ HttpClientCredentialStore.ClientCredential credential = HttpBackendTransportConnector.enroll(code,
+ "lobby-1", directory.resolve("durable-client"));
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(code, "lobby-1",
+ credential, envelope -> received.countDown())) {
+ connector.start();
+ assertTrue(received.await(8, TimeUnit.SECONDS));
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (countRegularFiles(queueDirectory) != 0L && System.nanoTime() < deadline) Thread.sleep(20);
+ assertEquals(0L, countRegularFiles(queueDirectory), "backend ACK must durably remove the delivery");
+ }
+ }
+ }
+
+ @Test
+ void pollCreatedBackendStateUsesDurableOutgoingQueue() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("poll-proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("poll-authority"));
+ Path queueDirectory = directory.resolve("poll-outgoing");
+ CountDownLatch proxyReceived = new CountDownLatch(1), backendReceived = new CountDownLatch(1);
+ CountDownLatch releaseBackendCallback = new CountDownLatch(1);
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0),
+ identity, authority, queueDirectory, ignored -> proxyReceived.countDown())) {
+ server.start();
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"),
+ Duration.ofMinutes(5));
+ HttpClientCredentialStore.ClientCredential credential = HttpBackendTransportConnector.enroll(code,
+ "lobby-1", directory.resolve("poll-client"));
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(code, "lobby-1",
+ credential, envelope -> {
+ backendReceived.countDown();
+ try { releaseBackendCallback.await(5, TimeUnit.SECONDS); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
+ })) {
+ connector.start();
+ assertTrue(connector.send(JsonEnvelope.builder("establish-poll").build()));
+ assertTrue(proxyReceived.await(8, TimeUnit.SECONDS));
+ assertTrue(server.send("lobby-1", JsonEnvelope.builder("durable-after-poll").build()));
+ assertTrue(backendReceived.await(8, TimeUnit.SECONDS));
+ assertEquals(1L, countRegularFiles(queueDirectory),
+ "a poll-created backend state must persist before reporting acceptance");
+ releaseBackendCallback.countDown();
+ }
+ } finally {
+ releaseBackendCallback.countDown();
+ }
+ }
+
+ private static long countRegularFiles(Path root) throws Exception {
+ try (java.util.stream.Stream paths = java.nio.file.Files.walk(root)) {
+ return paths.filter(path -> java.nio.file.Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).count();
+ }
+ }
+
+ @Test
+ void aggregatePacketBudgetSplitsLargeValidEnvelopes() {
+ java.util.List candidates = new java.util.ArrayList<>();
+ for (int index = 0; index < 12; index++) candidates.add(new HttpTransportProtocol.Delivery(
+ java.util.UUID.randomUUID().toString(), JsonEnvelope.builder("large").put("value", "x".repeat(40_000)).build()));
+ String session = java.util.UUID.randomUUID().toString();
+ java.util.List fitted = HttpTransportProtocol.fittingMessages(
+ "lobby-1", session, 0, java.util.List.of(), java.util.List.of(), candidates);
+ assertTrue(fitted.size() > 0 && fitted.size() < candidates.size());
+ assertTrue(HttpTransportProtocol.request("lobby-1", session, 0, java.util.List.of(), java.util.List.of(), fitted).length
+ <= HttpTransportProtocol.MAX_BODY_BYTES);
+ }
+
+ @Test
+ void packetNumbersMustUseCanonicalJsonIntegerTokens() {
+ com.google.gson.JsonObject packet = com.google.gson.JsonParser.parseString(new String(HttpTransportProtocol.request(
+ "lobby-1", java.util.UUID.randomUUID().toString(), 0, java.util.List.of(), java.util.List.of(), java.util.List.of()),
+ java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
+ assertDoesNotThrow(() -> HttpTransportProtocol.parsePacket(packet.toString()
+ .getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ String timestamp = packet.get("timestamp").getAsString();
+ java.util.Map> invalid = java.util.Map.of(
+ "v", java.util.List.of("\"1\"", "1.0", "1e0"),
+ "sequence", java.util.List.of("\"0\"", "0.0", "0e0"),
+ "timestamp", java.util.List.of("\"" + timestamp + "\"", timestamp + ".0", timestamp + "e0"));
+ for (var field : invalid.entrySet()) for (String token : field.getValue()) {
+ com.google.gson.JsonObject rejected = packet.deepCopy();
+ rejected.add(field.getKey(), com.google.gson.JsonParser.parseString(token));
+ assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket(
+ rejected.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)), field.getKey() + "=" + token);
+ }
+ }
+
+ @Test
+ void packetParsingRejectsNoncanonicalUuidForms() {
+ String deliveryId = java.util.UUID.randomUUID().toString();
+ com.google.gson.JsonObject packet = com.google.gson.JsonParser.parseString(new String(HttpTransportProtocol.request(
+ "lobby-1", java.util.UUID.randomUUID().toString(), 0, java.util.List.of(deliveryId),
+ java.util.List.of(deliveryId),
+ java.util.List.of(new HttpTransportProtocol.Delivery(deliveryId, JsonEnvelope.builder("payload").build()))),
+ java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
+ String abbreviated = "1-1-1-1-1";
+
+ com.google.gson.JsonObject invalidSession = packet.deepCopy();
+ invalidSession.addProperty("session", abbreviated);
+ assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket(
+ invalidSession.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+
+ com.google.gson.JsonObject invalidAck = packet.deepCopy();
+ invalidAck.getAsJsonArray("acks").set(0, new com.google.gson.JsonPrimitive(abbreviated));
+ assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket(
+ invalidAck.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ com.google.gson.JsonObject invalidConfirmation = packet.deepCopy();
+ invalidConfirmation.getAsJsonArray("ackConfirmations").set(0, new com.google.gson.JsonPrimitive(abbreviated));
+ assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket(
+ invalidConfirmation.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+
+ com.google.gson.JsonObject invalidMessage = packet.deepCopy();
+ invalidMessage.getAsJsonArray("messages").get(0).getAsJsonObject().addProperty("id", abbreviated);
+ assertThrows(IllegalArgumentException.class, () -> HttpTransportProtocol.parsePacket(
+ invalidMessage.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ void persistedProfileStartsAfterTheEnrollmentCodeExpires() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("authority"));
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity, authority, ignored -> { })) {
+ server.start();
+ HttpConnectionCode active = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5));
+ HttpBackendTransportConnector.enroll(active, "lobby-1", directory.resolve("client"));
+ HttpConnectionCode expired = new HttpConnectionCode(active.serverId(), active.endpoint(), active.serverCertificatePin(), active.caCertificatePin(),
+ java.time.Instant.now().minusSeconds(1), active.enrollmentToken());
+ try (HttpBackendTransportConnector ignored = new HttpBackendTransportConnector(expired, "lobby-1", directory.resolve("client"), message -> { })) {
+ assertTrue(true);
+ }
+ try (HttpBackendTransportConnector ignored = new HttpBackendTransportConnector(directory.resolve("client"), message -> { })) {
+ assertTrue(true);
+ }
+ }
+ }
+
+ @Test
+ void persistedBackendConnectsAfterAutomaticServerLeafRotation() throws Exception {
+ Instant now = Instant.now();
+ Path proxyDirectory = directory.resolve("proxy");
+ HttpTlsIdentity original = HttpTlsIdentity.loadOrCreate(proxyDirectory, "localhost",
+ java.time.Clock.fixed(now.minus(Duration.ofDays(340)), java.time.ZoneOffset.UTC));
+ String originalServerPin = HttpTransportSecrets.certificatePin(original.serverCertificate());
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(original, directory.resolve("authority"));
+ HttpTlsIdentity rotated = HttpTlsIdentity.loadOrCreate(proxyDirectory, "localhost");
+ CountDownLatch received = new CountDownLatch(1);
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), rotated,
+ authority, ignored -> received.countDown())) {
+ HttpConnectionCode activeCode = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5));
+ HttpTlsIdentity.IssuedClientCertificate issued = authority.enroll("lobby-1", activeCode.enrollmentToken());
+ HttpConnectionCode oldProfileCode = new HttpConnectionCode(activeCode.serverId(), activeCode.endpoint(), originalServerPin,
+ activeCode.caCertificatePin(), activeCode.expiresAt(), activeCode.enrollmentToken());
+ HttpClientCredentialStore.saveEnrolled(directory.resolve("client"), oldProfileCode, issued);
+ assertFalse(oldProfileCode.serverCertificatePin().equals(rotated.serverCertificatePin()));
+ server.start();
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(directory.resolve("client"), ignored -> { })) {
+ connector.start();
+ assertTrue(connector.send(JsonEnvelope.builder("after-rotation").build()));
+ assertTrue(received.await(8, TimeUnit.SECONDS));
+ }
+ }
+ }
+
+ @Test
+ void backendRenewsClientCertificateBeforeExpiryWithoutNewConnectionCode() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate expiring = identity.issueClientCertificate("lobby-1",
+ Instant.now().minus(Duration.ofDays(340)));
+ String originalPin = HttpTransportSecrets.certificatePin(expiring.certificate());
+ Path authorityDirectory = directory.resolve("authority");
+ java.nio.file.Files.createDirectories(authorityDirectory);
+ String key = java.util.Base64.getUrlEncoder().withoutPadding()
+ .encodeToString("lobby-1".getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ java.nio.file.Files.writeString(authorityDirectory.resolve("http-transport-clients.properties"),
+ "version=2\nbinding." + key + "=" + originalPin + ":-:0\n");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, authorityDirectory);
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity,
+ authority, ignored -> { })) {
+ HttpConnectionCode profileCode = new HttpConnectionCode("lobby-1", server.endpoint("localhost"),
+ identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "A".repeat(43));
+ Path clientDirectory = directory.resolve("client");
+ HttpClientCredentialStore.saveEnrolled(clientDirectory, profileCode, expiring);
+ server.start();
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(clientDirectory, ignored -> { })) {
+ connector.start();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(8);
+ String renewedPin = originalPin;
+ while (renewedPin.equals(originalPin) && System.nanoTime() < deadline) {
+ Thread.sleep(25);
+ renewedPin = HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(clientDirectory).certificate());
+ }
+ assertFalse(renewedPin.equals(originalPin));
+ HttpClientCredentialStore.ClientCredential renewed = HttpClientCredentialStore.load(clientDirectory);
+ long promotionDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (authority.authenticate("lobby-1", expiring.certificate()) && System.nanoTime() < promotionDeadline) Thread.sleep(25);
+ assertTrue(authority.authenticate("lobby-1", renewed.certificate()));
+ assertFalse(authority.authenticate("lobby-1", expiring.certificate()));
+ }
+ }
+ }
+
+ @Test
+ void duplicateInboundDeliveryIsReAcknowledgedWithoutSecondDispatch() {
+ HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState();
+ String session = java.util.UUID.randomUUID().toString();
+ String id = java.util.UUID.randomUUID().toString();
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("x").build());
+ assertTrue(state.acceptSession(session, 0));
+ assertEquals(1, state.acceptIncoming(java.util.List.of(delivery)).size());
+ state.completeIncoming(id, true);
+ assertEquals(java.util.List.of(id), state.await("lobby-1", session, 0).acks());
+ assertTrue(state.acceptSession(session, 1));
+ assertTrue(state.acceptIncoming(java.util.List.of(delivery)).isEmpty());
+ assertEquals(java.util.List.of(id), state.await("lobby-1", session, 1).acks());
+ String replacementSession = java.util.UUID.randomUUID().toString();
+ assertTrue(state.acceptSession(replacementSession, 0));
+ assertTrue(state.acceptIncoming(java.util.List.of(delivery)).isEmpty());
+ assertEquals(java.util.List.of(id), state.await("lobby-1", replacementSession, 0).acks());
+ }
+
+ @Test
+ void newerDeliveriesDoNotPostponeRetryOfOlderUnacknowledgedDelivery() {
+ AtomicLong nanoTime = new AtomicLong(1L);
+ HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState(nanoTime::get);
+ String session = java.util.UUID.randomUUID().toString();
+ HttpTransportProtocol.Delivery first = new HttpTransportProtocol.Delivery(
+ java.util.UUID.randomUUID().toString(), JsonEnvelope.builder("first").build());
+ HttpTransportProtocol.Delivery second = new HttpTransportProtocol.Delivery(
+ java.util.UUID.randomUUID().toString(), JsonEnvelope.builder("second").build());
+
+ assertTrue(state.acceptSession(session, 0));
+ assertTrue(state.enqueue(first));
+ assertEquals(java.util.List.of(first), state.await("lobby-1", session, 0).messages());
+
+ nanoTime.addAndGet(TimeUnit.SECONDS.toNanos(1));
+ assertTrue(state.acceptSession(session, 1));
+ assertTrue(state.enqueue(second));
+ assertEquals(java.util.List.of(second), state.await("lobby-1", session, 1).messages());
+
+ nanoTime.addAndGet(TimeUnit.MILLISECONDS.toNanos(1100));
+ assertTrue(state.acceptSession(session, 2));
+ assertEquals(java.util.List.of(first), state.await("lobby-1", session, 2).messages(),
+ "sending newer traffic must not reset an older delivery's retry age");
+ }
+
+ @Test
+ void longPollWakesAtTheOldestDeliveryRetryDeadline() throws Exception {
+ AtomicLong nanoTime = new AtomicLong(1L);
+ HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState(nanoTime::get);
+ String session = java.util.UUID.randomUUID().toString();
+ HttpTransportProtocol.Delivery first = new HttpTransportProtocol.Delivery(
+ java.util.UUID.randomUUID().toString(), JsonEnvelope.builder("first").build());
+ HttpTransportProtocol.Delivery second = new HttpTransportProtocol.Delivery(
+ java.util.UUID.randomUUID().toString(), JsonEnvelope.builder("second").build());
+ assertTrue(state.acceptSession(session, 0));
+ assertTrue(state.enqueue(first));
+ assertEquals(java.util.List.of(first), state.await("lobby-1", session, 0).messages());
+
+ nanoTime.addAndGet(HttpProxyTransportServer.LONG_POLL.minusMillis(100).toNanos());
+ assertTrue(state.acceptSession(session, 1));
+ assertTrue(state.enqueue(second));
+ assertEquals(java.util.List.of(second), state.await("lobby-1", session, 1).messages());
+ assertTrue(state.acceptSession(session, 2));
+ Thread clock = new Thread(() -> {
+ try { Thread.sleep(50L); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
+ nanoTime.addAndGet(TimeUnit.MILLISECONDS.toNanos(100));
+ }, "HTTP-retry-test-clock");
+ clock.setDaemon(true);
+ long started = System.nanoTime();
+ clock.start();
+ assertEquals(java.util.List.of(first), state.await("lobby-1", session, 2).messages());
+ clock.join();
+ assertTrue(System.nanoTime() - started < TimeUnit.SECONDS.toNanos(1),
+ "the poll must wake at the oldest delivery deadline, not a fresh long-poll deadline");
+ }
+
+ @Test
+ void proxyDedupWindowEvictsOldestCompletedDeliveryAtCapacity() {
+ HttpProxyTransportServer.BackendState state = new HttpProxyTransportServer.BackendState();
+ String oldest = null, newest = null;
+ for (int index = 0; index < HttpTransportProtocol.MAX_QUEUE + 1; index++) {
+ String id = java.util.UUID.randomUUID().toString();
+ if (index == 0) oldest = id;
+ newest = id;
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("x").build());
+ assertEquals(1, state.acceptIncoming(java.util.List.of(delivery)).size());
+ state.completeIncoming(id, true);
+ }
+ HttpTransportProtocol.Delivery evicted = new HttpTransportProtocol.Delivery(oldest, JsonEnvelope.builder("x").build());
+ HttpTransportProtocol.Delivery retained = new HttpTransportProtocol.Delivery(newest, JsonEnvelope.builder("x").build());
+ assertEquals(1, state.acceptIncoming(java.util.List.of(evicted)).size());
+ assertTrue(state.acceptIncoming(java.util.List.of(retained)).isEmpty());
+ }
+
+ @Test
+ void backendReAcknowledgesLostAckDuplicateWithoutSecondCallback() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1");
+ HttpClientCredentialStore.save(directory.resolve("client"), issued);
+ HttpClientCredentialStore.HttpClientProfile profile = new HttpClientCredentialStore.HttpClientProfile("lobby-1",
+ java.net.URI.create("https://localhost:8443/"), identity.serverCertificatePin(), identity.caCertificatePin());
+ CountDownLatch callback = new CountDownLatch(1);
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(profile,
+ HttpClientCredentialStore.load(directory.resolve("client")), envelope -> callback.countDown())) {
+ String id = java.util.UUID.randomUUID().toString();
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("x").build());
+ connector.dispatch(delivery);
+ assertTrue(callback.await(2, TimeUnit.SECONDS));
+ java.util.List acknowledgements = java.util.List.of();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (acknowledgements.isEmpty() && System.nanoTime() < deadline) {
+ acknowledgements = connector.drainAcknowledgements();
+ if (acknowledgements.isEmpty()) Thread.sleep(5);
+ }
+ assertEquals(java.util.List.of(id), acknowledgements);
+ assertTrue(connector.accept(java.util.List.of(delivery)).isEmpty());
+ assertEquals(java.util.List.of(id), connector.drainAcknowledgements());
+ }
+ }
+
+ @Test
+ void backendCallbacksAreSerializedInDeliveryOrder() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("ordered-proxy"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1");
+ HttpClientCredentialStore.save(directory.resolve("ordered-client"), issued);
+ HttpClientCredentialStore.HttpClientProfile profile = new HttpClientCredentialStore.HttpClientProfile("lobby-1",
+ java.net.URI.create("https://localhost:8443/"), identity.serverCertificatePin(), identity.caCertificatePin());
+ CountDownLatch firstStarted = new CountDownLatch(1), releaseFirst = new CountDownLatch(1), secondStarted = new CountDownLatch(1);
+ java.util.List order = new java.util.concurrent.CopyOnWriteArrayList<>();
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(profile,
+ HttpClientCredentialStore.load(directory.resolve("ordered-client")), envelope -> {
+ String marker = String.valueOf(envelope.getFields().get("marker"));
+ order.add(marker);
+ if ("first".equals(marker)) {
+ firstStarted.countDown();
+ try { releaseFirst.await(5, TimeUnit.SECONDS); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
+ } else secondStarted.countDown();
+ })) {
+ connector.dispatch(new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(),
+ JsonEnvelope.builder("x").put("marker", "first").build()));
+ connector.dispatch(new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(),
+ JsonEnvelope.builder("x").put("marker", "second").build()));
+ assertTrue(firstStarted.await(2, TimeUnit.SECONDS));
+ assertFalse(secondStarted.await(150, TimeUnit.MILLISECONDS));
+ releaseFirst.countDown();
+ assertTrue(secondStarted.await(2, TimeUnit.SECONDS));
+ assertEquals(java.util.List.of("first", "second"), order);
+ } finally { releaseFirst.countDown(); }
+ }
+
+ @Test
+ void backendCallbackQueueBackpressuresWithoutBreakingFifo() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("backpressure-proxy"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1");
+ HttpClientCredentialStore.save(directory.resolve("backpressure-client"), issued);
+ HttpClientCredentialStore.HttpClientProfile profile = new HttpClientCredentialStore.HttpClientProfile("lobby-1",
+ java.net.URI.create("https://localhost:8443/"), identity.serverCertificatePin(), identity.caCertificatePin());
+ CountDownLatch firstStarted = new CountDownLatch(1), releaseFirst = new CountDownLatch(1);
+ int deliveries = HttpBackendTransportConnector.CALLBACK_QUEUE_CAPACITY + 2;
+ CountDownLatch completed = new CountDownLatch(deliveries), overflowSubmitted = new CountDownLatch(1);
+ java.util.List order = new java.util.concurrent.CopyOnWriteArrayList<>();
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(profile,
+ HttpClientCredentialStore.load(directory.resolve("backpressure-client")), envelope -> {
+ int marker = Integer.parseInt(envelope.getFields().get("marker"));
+ order.add(marker);
+ if (marker == 0) {
+ firstStarted.countDown();
+ try { releaseFirst.await(5, TimeUnit.SECONDS); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
+ }
+ completed.countDown();
+ })) {
+ connector.dispatch(delivery(0));
+ assertTrue(firstStarted.await(2, TimeUnit.SECONDS));
+ for (int marker = 1; marker <= HttpBackendTransportConnector.CALLBACK_QUEUE_CAPACITY; marker++)
+ connector.dispatch(delivery(marker));
+ Thread overflow = new Thread(() -> {
+ connector.dispatch(delivery(deliveries - 1));
+ overflowSubmitted.countDown();
+ }, "HTTP-overflow-submitter");
+ overflow.start();
+ assertFalse(overflowSubmitted.await(150, TimeUnit.MILLISECONDS), "a full ordered lane must backpressure its producer");
+ releaseFirst.countDown();
+ assertTrue(overflowSubmitted.await(2, TimeUnit.SECONDS));
+ assertTrue(completed.await(5, TimeUnit.SECONDS));
+ assertEquals(java.util.stream.IntStream.range(0, deliveries).boxed().toList(), order);
+ } finally { releaseFirst.countDown(); }
+ }
+
+ @Test
+ void durableBackendFencePreventsCallbackReplayAfterRestartBeforeAck() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("fence-proxy"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1");
+ HttpConnectionCode code = new HttpConnectionCode("lobby-1", java.net.URI.create("https://localhost:8443/"),
+ identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(300), "A".repeat(43));
+ Path clientDirectory = directory.resolve("fence-client");
+ HttpClientCredentialStore.saveEnrolled(clientDirectory, code, issued);
+ java.util.concurrent.atomic.AtomicInteger callbacks = new java.util.concurrent.atomic.AtomicInteger();
+ String id = java.util.UUID.randomUUID().toString();
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("vote").build());
+ try (HttpBackendTransportConnector first = new HttpBackendTransportConnector(clientDirectory,
+ ignored -> callbacks.incrementAndGet())) {
+ first.dispatch(delivery);
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (callbacks.get() != 1 && System.nanoTime() < deadline) Thread.sleep(5);
+ assertEquals(1, callbacks.get());
+ }
+ try (HttpBackendTransportConnector restarted = new HttpBackendTransportConnector(clientDirectory,
+ ignored -> callbacks.incrementAndGet())) {
+ assertEquals(java.util.List.of(id), restarted.drainAcknowledgements(),
+ "restart must retain and acknowledge the pre-callback delivery fence");
+ assertTrue(restarted.accept(java.util.List.of(delivery)).isEmpty());
+ assertEquals(1, callbacks.get(), "a durable proxy replay must not award twice");
+ }
+ }
+
+ @Test
+ void failedBackendCallbackRemainsUnacknowledgedAndIsNotReplayed() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("retry-fence-proxy"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1");
+ HttpConnectionCode code = new HttpConnectionCode("lobby-1", java.net.URI.create("https://localhost:8443/"),
+ identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(300), "B".repeat(43));
+ Path clientDirectory = directory.resolve("retry-fence-client");
+ HttpClientCredentialStore.saveEnrolled(clientDirectory, code, issued);
+ java.util.concurrent.atomic.AtomicInteger attempts = new java.util.concurrent.atomic.AtomicInteger();
+ CountDownLatch failed = new CountDownLatch(1);
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(),
+ JsonEnvelope.builder("vote").build());
+ try (HttpBackendTransportConnector first = new HttpBackendTransportConnector(clientDirectory, ignored -> {
+ attempts.incrementAndGet(); failed.countDown(); throw new IllegalStateException("retry");
+ })) {
+ first.dispatch(delivery);
+ assertTrue(failed.await(2, TimeUnit.SECONDS));
+ assertTrue(first.drainAcknowledgements().isEmpty());
+ }
+ try (HttpBackendTransportConnector restarted = new HttpBackendTransportConnector(clientDirectory,
+ ignored -> attempts.incrementAndGet())) {
+ assertTrue(restarted.drainAcknowledgements().isEmpty());
+ java.util.List accepted = restarted.accept(java.util.List.of(delivery));
+ assertTrue(accepted.isEmpty(), "an ambiguous callback must not be awarded twice");
+ assertEquals(1, attempts.get());
+ }
+ }
+
+ @Test
+ void reservedButNotStartedDeliveryResumesAfterRestart() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("reserved-proxy"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1");
+ HttpConnectionCode code = new HttpConnectionCode("lobby-1", java.net.URI.create("https://localhost:8443/"),
+ identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(300), "C".repeat(43));
+ Path clientDirectory = directory.resolve("reserved-client");
+ HttpClientCredentialStore.saveEnrolled(clientDirectory, code, issued);
+ String id = java.util.UUID.randomUUID().toString();
+ new HttpInboundDeliveryStore(clientDirectory).reserve(id);
+ CountDownLatch completed = new CountDownLatch(1);
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("vote").build());
+ try (HttpBackendTransportConnector restarted = new HttpBackendTransportConnector(clientDirectory, ignored -> completed.countDown())) {
+ assertTrue(restarted.drainAcknowledgements().isEmpty(), "a reservation alone must never be acknowledged");
+ java.util.List accepted = restarted.accept(java.util.List.of(delivery));
+ assertEquals(1, accepted.size());
+ restarted.dispatch(accepted.get(0));
+ assertTrue(completed.await(2, TimeUnit.SECONDS));
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ java.util.List acknowledgements = java.util.List.of();
+ while (acknowledgements.isEmpty() && System.nanoTime() < deadline) {
+ acknowledgements = restarted.drainAcknowledgements();
+ if (acknowledgements.isEmpty()) Thread.sleep(5);
+ }
+ assertEquals(java.util.List.of(id), acknowledgements);
+ }
+ }
+
+ @Test
+ void interruptedStateRenameRetainsTheFurthestSafeState() throws Exception {
+ Path clientDirectory = directory.resolve("interrupted-state-client");
+ String id = java.util.UUID.randomUUID().toString();
+ String completedId = java.util.UUID.randomUUID().toString();
+ Path states = clientDirectory.resolve("http-transport-inbound-deliveries");
+ Files.createDirectories(states);
+ Files.writeString(states.resolve(id + ".reserved"), id);
+ Files.writeString(states.resolve(id + ".running"), id);
+ Files.writeString(states.resolve(completedId + ".running"), completedId);
+ Files.writeString(states.resolve(completedId + ".completed"), completedId);
+ HttpInboundDeliveryStore store = new HttpInboundDeliveryStore(clientDirectory);
+ assertEquals(HttpInboundDeliveryStore.State.RUNNING, store.state(id));
+ assertEquals(HttpInboundDeliveryStore.State.COMPLETED, store.state(completedId));
+ assertFalse(Files.exists(states.resolve(id + ".reserved")));
+ assertTrue(Files.exists(states.resolve(id + ".running")));
+ assertFalse(Files.exists(states.resolve(completedId + ".running")));
+ assertTrue(Files.exists(states.resolve(completedId + ".completed")));
+ }
+
+ private static HttpTransportProtocol.Delivery delivery(int marker) {
+ return new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(),
+ JsonEnvelope.builder("x").put("marker", marker).build());
+ }
+
+ @Test
+ void proxyCallbacksAreSerializedInDeliveryOrder() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("ordered-proxy-server"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("ordered-authority"));
+ CountDownLatch firstStarted = new CountDownLatch(1), releaseFirst = new CountDownLatch(1), secondStarted = new CountDownLatch(1);
+ java.util.List order = new java.util.concurrent.CopyOnWriteArrayList<>();
+ try (HttpProxyTransportServer server = new HttpProxyTransportServer(new InetSocketAddress("localhost", 0), identity,
+ authority, received -> {
+ String marker = String.valueOf(received.envelope().getFields().get("marker"));
+ order.add(marker);
+ if ("first".equals(marker)) {
+ firstStarted.countDown();
+ try { releaseFirst.await(5, TimeUnit.SECONDS); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
+ } else secondStarted.countDown();
+ })) {
+ server.start();
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", server.endpoint("localhost"), Duration.ofMinutes(5));
+ HttpClientCredentialStore.ClientCredential credential = HttpBackendTransportConnector.enroll(code, "lobby-1",
+ directory.resolve("ordered-proxy-client"));
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(code, "lobby-1", credential,
+ ignored -> { })) {
+ connector.start();
+ assertTrue(connector.send(JsonEnvelope.builder("x").put("marker", "first").build()));
+ assertTrue(connector.send(JsonEnvelope.builder("x").put("marker", "second").build()));
+ assertTrue(firstStarted.await(3, TimeUnit.SECONDS));
+ assertFalse(secondStarted.await(150, TimeUnit.MILLISECONDS));
+ releaseFirst.countDown();
+ assertTrue(secondStarted.await(3, TimeUnit.SECONDS));
+ assertEquals(java.util.List.of("first", "second"), order);
+ }
+ } finally { releaseFirst.countDown(); }
+ }
+
+ @Test
+ void backendDedupWindowContinuesAfterCapacity() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate issued = identity.issueClientCertificate("lobby-1");
+ HttpClientCredentialStore.save(directory.resolve("client"), issued);
+ HttpClientCredentialStore.HttpClientProfile profile = new HttpClientCredentialStore.HttpClientProfile("lobby-1",
+ java.net.URI.create("https://localhost:8443/"), identity.serverCertificatePin(), identity.caCertificatePin());
+ try (HttpBackendTransportConnector connector = new HttpBackendTransportConnector(profile,
+ HttpClientCredentialStore.load(directory.resolve("client")), envelope -> { })) {
+ for (int index = 0; index < HttpTransportProtocol.MAX_QUEUE; index++) {
+ String id = java.util.UUID.randomUUID().toString();
+ HttpTransportProtocol.Delivery delivery = new HttpTransportProtocol.Delivery(id, JsonEnvelope.builder("x").build());
+ assertEquals(1, connector.accept(java.util.List.of(delivery)).size());
+ connector.completeIncoming(id, true);
+ }
+ HttpTransportProtocol.Delivery next = new HttpTransportProtocol.Delivery(java.util.UUID.randomUUID().toString(),
+ JsonEnvelope.builder("x").build());
+ assertEquals(1, connector.accept(java.util.List.of(next)).size());
+ }
+ }
+}
diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/HttpTransportSecurityTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/HttpTransportSecurityTest.java
new file mode 100644
index 0000000..c3e196b
--- /dev/null
+++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/HttpTransportSecurityTest.java
@@ -0,0 +1,545 @@
+package com.bencodez.simpleapi.servercomm.http;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.cert.X509Certificate;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import javax.net.ssl.X509TrustManager;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class HttpTransportSecurityTest {
+ @Test
+ void failedRenewalRetriesBeforeActiveCertificateExpires() {
+ assertEquals(Duration.ofMinutes(5),
+ HttpBackendTransportConnector.renewalRetryDelay(Duration.ofHours(6)));
+ assertEquals(Duration.ofMinutes(1),
+ HttpBackendTransportConnector.renewalRetryDelay(Duration.ofMinutes(4)));
+ assertTrue(HttpBackendTransportConnector.renewalRetryDelay(Duration.ofSeconds(3))
+ .compareTo(Duration.ofSeconds(3)) < 0);
+ }
+
+ @Test
+ void connectionCodeRejectsExplicitZeroPort() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new HttpConnectionCode("lobby", URI.create("https://proxy.example.test:0/"), pin('a'),
+ pin('b'), Instant.now().plusSeconds(60), "token"));
+ }
+
+ @TempDir Path directory;
+
+ @Test
+ void backendResponseReaderRejectsBodiesBeyondTheWireLimit() throws Exception {
+ byte[] maximum = new byte[HttpTransportProtocol.MAX_BODY_BYTES];
+ assertEquals(maximum.length, HttpBackendTransportConnector.readLimited(
+ new java.io.ByteArrayInputStream(maximum)).length);
+ assertThrows(java.io.IOException.class, () -> HttpBackendTransportConnector.readLimited(
+ new java.io.ByteArrayInputStream(new byte[HttpTransportProtocol.MAX_BODY_BYTES + 1])));
+ }
+
+ @Test
+ void connectionCodeRoundTripsAndRejectsAccidentalCorruption() {
+ HttpConnectionCode original = new HttpConnectionCode("lobby.eu", URI.create("https://Proxy.Example.test:8443/http"), pin('a'), pin('b'),
+ Instant.parse("2030-01-01T00:00:00Z"), HttpTransportSecrets.randomToken());
+ String encoded = original.encode();
+ HttpConnectionCode parsed = HttpConnectionCode.parse(encoded);
+ assertEquals("lobby.eu", parsed.serverId());
+ assertEquals(URI.create("https://proxy.example.test:8443/http/"), parsed.endpoint());
+ assertEquals(original.serverCertificatePin(), parsed.serverCertificatePin());
+ char last = encoded.charAt(encoded.length() - 1);
+ assertThrows(IllegalArgumentException.class, () -> HttpConnectionCode.parse(encoded.substring(0, encoded.length() - 1)
+ + (last == 'A' ? 'B' : 'A')));
+ assertThrows(IllegalArgumentException.class, () -> HttpConnectionCode.parse("http://not-a-code"));
+ }
+
+ @Test
+ void legacyConnectionCodesAndConsumedMarkersRemainCompatible() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("legacy-code-proxy"), "proxy.example.test");
+ HttpConnectionCode legacy = new HttpConnectionCode("lobby", URI.create("https://proxy.example.test:8443/"),
+ identity.serverCertificatePin(), identity.caCertificatePin(),
+ Instant.now().plusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
+ HttpTransportSecrets.randomToken());
+ assertEquals(legacy, HttpConnectionCode.parse(legacy.encodeLegacy()));
+
+ Path client = directory.resolve("legacy-code-client");
+ HttpClientCredentialStore.saveEnrolled(client, legacy, identity.issueClientCertificate("lobby"));
+ Path active = client.resolve("http-transport-client-generations")
+ .resolve(Files.readString(client.resolve("http-transport-client-current")));
+ Files.writeString(active.resolve("http-transport-connection-code.sha256"),
+ HttpTransportSecrets.sha256Hex(legacy.encodeLegacy().getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
+ assertTrue(HttpClientCredentialStore.matchesEnrollmentCode(client,
+ HttpConnectionCode.parse(legacy.encodeLegacy())));
+ }
+
+ @Test
+ void expiredCodesAreNotActive() {
+ HttpConnectionCode code = new HttpConnectionCode("lobby", URI.create("https://proxy.example.test/"), pin('a'), pin('b'),
+ Instant.parse("2029-12-31T23:59:59Z"), HttpTransportSecrets.randomToken());
+ assertFalse(code.isActive(Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC)));
+ assertThrows(IllegalArgumentException.class, () -> code.requireActive(Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC)));
+ }
+
+ @Test
+ void inboundDeliveryFenceRejectsCorruptionAndPathReplacement() throws Exception {
+ Path corruptCredentials = directory.resolve("corrupt-client");
+ Path corruptFence = corruptCredentials.resolve("http-transport-inbound-deliveries");
+ Files.createDirectories(corruptFence);
+ Files.writeString(corruptFence.resolve("not-a-delivery.seen"), "not-a-delivery");
+ assertThrows(java.io.IOException.class, () -> new HttpInboundDeliveryStore(corruptCredentials));
+
+ Path replacedCredentials = directory.resolve("replaced-client");
+ Files.createDirectories(replacedCredentials);
+ HttpInboundDeliveryStore store = new HttpInboundDeliveryStore(replacedCredentials);
+ Path fence = replacedCredentials.resolve("http-transport-inbound-deliveries");
+ Path outside = directory.resolve("outside-fence");
+ Files.createDirectory(outside);
+ Files.delete(fence);
+ Files.createSymbolicLink(fence, outside);
+ assertThrows(java.io.IOException.class, () -> store.reserve(java.util.UUID.randomUUID().toString()));
+ }
+
+ @Test
+ void sealedInboundStoreCannotChangeAfterOwnershipHandoff() throws Exception {
+ Path credentials = directory.resolve("sealed-client");
+ Files.createDirectories(credentials);
+ String id = java.util.UUID.randomUUID().toString();
+ HttpInboundDeliveryStore store = new HttpInboundDeliveryStore(credentials);
+ store.reserve(id);
+ store.markRunning(id);
+ store.seal();
+ assertThrows(java.io.IOException.class, () -> store.markCompleted(id));
+ assertEquals(HttpInboundDeliveryStore.State.RUNNING, new HttpInboundDeliveryStore(credentials).state(id));
+ }
+
+ @Test
+ void identityIsDurableAndPinsRejectTheWrongServer() throws Exception {
+ HttpTlsIdentity created = HttpTlsIdentity.loadOrCreate(directory, "localhost");
+ HttpTlsIdentity loaded = HttpTlsIdentity.loadOrCreate(directory, "localhost");
+ assertEquals(created.serverCertificatePin(), loaded.serverCertificatePin());
+ assertEquals(created.caCertificatePin(), loaded.caCertificatePin());
+ HttpConnectionCode correct = new HttpConnectionCode("lobby", URI.create("https://localhost:8443/"), created.serverCertificatePin(),
+ created.caCertificatePin(), Instant.now().plusSeconds(60), HttpTransportSecrets.randomToken());
+ HttpConnectionCode incorrect = new HttpConnectionCode("lobby", URI.create("https://localhost:8443/"), pin('0'), created.caCertificatePin(),
+ Instant.now().plusSeconds(60), HttpTransportSecrets.randomToken());
+ assertTrue(HttpPinnedTls.matchesServerPin(correct, created.serverCertificate()));
+ assertFalse(HttpPinnedTls.matchesServerPin(incorrect, created.serverCertificate()));
+ assertTrue(Files.exists(directory.resolve("http-transport-ca.p12")));
+ HttpTlsIdentity rotated = HttpTlsIdentity.loadOrCreate(directory, "127.0.0.1");
+ assertEquals(created.caCertificatePin(), rotated.caCertificatePin());
+ assertNotEquals(created.serverCertificatePin(), rotated.serverCertificatePin());
+ }
+
+ @Test
+ void privateCredentialRootsRejectSymbolicLinks() throws Exception {
+ Path identityTarget = directory.resolve("identity-target");
+ Path identityLink = directory.resolve("identity-link");
+ Files.createDirectory(identityTarget);
+ Files.createSymbolicLink(identityLink, identityTarget);
+ assertThrows(java.io.IOException.class, () -> HttpTlsIdentity.loadOrCreate(identityLink, "localhost"));
+ assertFalse(Files.exists(identityTarget.resolve("http-transport-ca.p12")));
+
+ HttpTlsIdentity authority = HttpTlsIdentity.loadOrCreate(directory.resolve("safe-identity"), "localhost");
+ HttpTlsIdentity.IssuedClientCertificate issued = authority.issueClientCertificate("lobby-1");
+ Path credentialTarget = directory.resolve("credential-target");
+ Path credentialLink = directory.resolve("credential-link");
+ Files.createDirectory(credentialTarget);
+ Files.createSymbolicLink(credentialLink, credentialTarget);
+ assertThrows(java.io.IOException.class, () -> HttpClientCredentialStore.save(credentialLink, issued));
+ HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://localhost:8443/"),
+ authority.serverCertificatePin(), authority.caCertificatePin(), Instant.now().plusSeconds(60), "A".repeat(43));
+ assertThrows(java.io.IOException.class, () -> HttpClientCredentialStore.saveEnrolled(credentialLink, code, issued));
+ try (var files = Files.list(credentialTarget)) {
+ assertTrue(files.findAny().isEmpty(), "a symlinked credential root must receive no private files");
+ }
+ }
+
+ @Test
+ void markedIncompleteFirstRunTlsProvisioningRecoversWithoutManualCleanup() throws Exception {
+ Path source = directory.resolve("complete-identity");
+ HttpTlsIdentity original = HttpTlsIdentity.loadOrCreate(source, "localhost");
+ Path interrupted = directory.resolve("interrupted-identity");
+ Files.createDirectories(interrupted);
+ Files.writeString(interrupted.resolve("http-transport-initializing"), "initializing\n");
+ Files.copy(source.resolve("http-transport-ca.p12"), interrupted.resolve("http-transport-ca.p12"));
+ Files.copy(source.resolve("http-transport-server.p12"), interrupted.resolve("http-transport-server.p12"));
+
+ HttpTlsIdentity recovered = HttpTlsIdentity.loadOrCreate(interrupted, "localhost");
+
+ assertNotEquals(original.caCertificatePin(), recovered.caCertificatePin());
+ assertTrue(Files.exists(interrupted.resolve("http-transport-ca.p12")));
+ assertTrue(Files.exists(interrupted.resolve("http-transport-server.p12")));
+ assertTrue(Files.exists(interrupted.resolve("http-transport-password")));
+ assertFalse(Files.exists(interrupted.resolve("http-transport-initializing")));
+ }
+
+ @Test
+ void unmarkedPartialIdentityFailsClosedWithExternalTransportState() throws Exception {
+ Path source = directory.resolve("external-state-source");
+ HttpTlsIdentity.loadOrCreate(source, "localhost");
+ Path partial = directory.resolve("external-state-partial");
+ Files.createDirectories(partial);
+ Files.copy(source.resolve("http-transport-ca.p12"), partial.resolve("http-transport-ca.p12"));
+ byte[] retainedCa = Files.readAllBytes(partial.resolve("http-transport-ca.p12"));
+ Path externalState = directory.resolve("external-authority");
+ Files.createDirectories(externalState);
+ Files.writeString(externalState.resolve("http-transport-clients.properties"), "version=3\n");
+
+ assertThrows(java.io.IOException.class, () -> HttpTlsIdentity.loadOrCreate(partial, "localhost"));
+ assertTrue(Arrays.equals(retainedCa, Files.readAllBytes(partial.resolve("http-transport-ca.p12"))),
+ "fail-closed recovery must preserve the surviving CA bytes");
+ }
+
+ @Test
+ void completedFirstRunFilesRecoverWhenInitializationMarkerSurvives() throws Exception {
+ Path interrupted = directory.resolve("marked-identity");
+ HttpTlsIdentity original = HttpTlsIdentity.loadOrCreate(interrupted, "localhost");
+ String originalCaPin = original.caCertificatePin();
+ Files.writeString(interrupted.resolve("http-transport-initializing"), "initializing\n");
+
+ HttpTlsIdentity recovered = HttpTlsIdentity.loadOrCreate(interrupted, "localhost");
+
+ assertNotEquals(originalCaPin, recovered.caCertificatePin());
+ assertFalse(Files.exists(interrupted.resolve("http-transport-initializing")));
+ }
+
+ @Test
+ void incompleteEstablishedTlsIdentityFailsClosed() throws Exception {
+ Path established = directory.resolve("established-identity");
+ HttpTlsIdentity.loadOrCreate(established, "localhost");
+ Files.writeString(established.resolve("http-transport-clients.properties"), "version=2\n");
+ Files.delete(established.resolve("http-transport-server.p12"));
+
+ assertThrows(java.io.IOException.class, () -> HttpTlsIdentity.loadOrCreate(established, "localhost"));
+ assertTrue(Files.exists(established.resolve("http-transport-ca.p12")));
+ assertTrue(Files.exists(established.resolve("http-transport-password")));
+ }
+
+ @Test
+ void enrollmentIsSingleUseBoundToServerAndRevocable() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory, "localhost");
+ Clock clock = Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC);
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, clock);
+ HttpConnectionCode wrongTargetCode = authority.createConnectionCode("lobby-1", URI.create("https://localhost:8443/"), Duration.ofMinutes(5));
+ assertThrows(IllegalArgumentException.class, () -> authority.enroll("attacker", wrongTargetCode.enrollmentToken()));
+ assertTrue(authority.authenticate("lobby-1", authority.enroll("lobby-1", wrongTargetCode.enrollmentToken()).certificate()),
+ "a wrong backend must not consume another backend's connection code");
+ authority.revoke("lobby-1");
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", URI.create("https://localhost:8443/"), Duration.ofMinutes(5));
+ HttpTlsIdentity.IssuedClientCertificate issued = authority.enroll("lobby-1", code.enrollmentToken());
+ assertTrue(authority.authenticate("lobby-1", issued.certificate()));
+ assertTrue(identity.validClientCertificate("LOBBY-1", issued.certificate()));
+ assertFalse(authority.authenticate("lobby-2", issued.certificate()));
+ assertThrows(IllegalArgumentException.class, () -> authority.enroll("lobby-2", code.enrollmentToken()));
+ authority.revoke("lobby-1");
+ assertFalse(authority.authenticate("lobby-1", issued.certificate()));
+ HttpConnectionCode replacementCode = authority.createConnectionCode("lobby-1", URI.create("https://localhost:8443/"), Duration.ofMinutes(5));
+ HttpTlsIdentity.IssuedClientCertificate replacement = authority.enroll("lobby-1", replacementCode.enrollmentToken());
+ assertTrue(authority.authenticate("lobby-1", replacement.certificate()));
+ assertFalse(authority.authenticate("lobby-1", issued.certificate()));
+ HttpClientCredentialStore.saveEnrolled(directory.resolve("client"), code, issued);
+ HttpClientCredentialStore.ClientCredential restored = HttpClientCredentialStore.load(directory.resolve("client"));
+ assertEquals(HttpTransportSecrets.certificatePin(issued.certificate()), HttpTransportSecrets.certificatePin(restored.certificate()));
+ HttpClientCredentialStore.HttpClientProfile profile = HttpClientCredentialStore.loadProfile(directory.resolve("client"));
+ assertEquals("lobby-1", profile.serverId());
+ assertEquals(code.endpoint(), profile.endpoint());
+ assertEquals("lobby-1", HttpClientCredentialStore.loadEnrolled(directory.resolve("client")).profile().serverId());
+ assertNotEquals(null, HttpPinnedTls.mutualTlsContext(code, restored));
+ Path clientDirectory = directory.resolve("client");
+ String generation = Files.readString(clientDirectory.resolve("http-transport-client-current"));
+ Files.writeString(clientDirectory.resolve("http-transport-client-generations").resolve(generation)
+ .resolve("http-transport-profile.properties"), "version=1\nserverId=lobby-1\n");
+ assertThrows(java.io.IOException.class, () -> HttpClientCredentialStore.loadProfile(directory.resolve("client")));
+ HttpEnrollmentAuthority durable = new HttpEnrollmentAuthority(identity, directory.resolve("state"));
+ HttpConnectionCode durableCode = durable.createConnectionCode("survival", URI.create("https://localhost:8443/"), Duration.ofMinutes(5));
+ HttpTlsIdentity.IssuedClientCertificate durableIssued = durable.enroll("survival", durableCode.enrollmentToken());
+ assertTrue(new HttpEnrollmentAuthority(identity, directory.resolve("state")).authenticate("survival", durableIssued.certificate()));
+ durable.revoke("survival");
+ assertFalse(new HttpEnrollmentAuthority(identity, directory.resolve("state")).authenticate("survival", durableIssued.certificate()));
+ }
+
+ @Test
+ void revocationInvalidatesEveryPendingCodeForTheBackend() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("revoke-pending"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity,
+ Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC));
+ URI endpoint = URI.create("https://localhost:8443/");
+ HttpConnectionCode beforeEnrollment = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5));
+ authority.revoke("LOBBY-1");
+ assertThrows(IllegalArgumentException.class,
+ () -> authority.enroll("lobby-1", beforeEnrollment.enrollmentToken()));
+
+ HttpConnectionCode active = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5));
+ authority.enroll("lobby-1", active.enrollmentToken());
+ HttpConnectionCode firstPending = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5));
+ HttpConnectionCode secondPending = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5));
+ authority.revoke("lobby-1");
+ assertThrows(IllegalArgumentException.class,
+ () -> authority.enroll("lobby-1", firstPending.enrollmentToken()));
+ assertThrows(IllegalArgumentException.class,
+ () -> authority.enroll("lobby-1", secondPending.enrollmentToken()));
+ }
+
+ @Test
+ void failedRevocationPersistenceCanBeRetriedWithoutLosingState() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("retry-revoke-proxy"), "localhost");
+ Path stateDirectory = directory.resolve("retry-revoke-state");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, stateDirectory);
+ URI endpoint = URI.create("https://localhost:8443/");
+ HttpConnectionCode active = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5));
+ HttpTlsIdentity.IssuedClientCertificate issued = authority.enroll("lobby-1", active.enrollmentToken());
+ HttpConnectionCode pending = authority.createConnectionCode("lobby-1", endpoint, Duration.ofMinutes(5));
+ Path stateFile = stateDirectory.resolve("http-transport-clients.properties");
+ Files.delete(stateFile);
+ Files.createDirectory(stateFile);
+
+ assertThrows(IllegalStateException.class, () -> authority.revoke("lobby-1"));
+ assertFalse(authority.authenticate("lobby-1", issued.certificate()),
+ "an unpersisted revocation must fail authentication closed");
+ Files.delete(stateFile);
+ authority.revoke("lobby-1");
+
+ HttpEnrollmentAuthority restarted = new HttpEnrollmentAuthority(identity, stateDirectory);
+ assertFalse(restarted.authenticate("lobby-1", issued.certificate()));
+ assertThrows(IllegalArgumentException.class,
+ () -> restarted.enroll("lobby-1", pending.enrollmentToken()));
+ }
+
+ @Test
+ void pendingEnrollmentSurvivesRestartAndRevocationRemainsDurable() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("pending-restart-proxy"), "localhost");
+ Path state = directory.resolve("pending-restart-state");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, state);
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1",
+ URI.create("https://localhost:8443/"), Duration.ofMinutes(5));
+ assertFalse(Files.readString(state.resolve("http-transport-clients.properties"))
+ .contains(code.enrollmentToken()), "raw enrollment tokens must never be persisted");
+
+ HttpEnrollmentAuthority restarted = new HttpEnrollmentAuthority(identity, state);
+ HttpTlsIdentity.IssuedClientCertificate issued = restarted.enroll("lobby-1", code.enrollmentToken());
+ assertTrue(restarted.authenticate("lobby-1", issued.certificate()));
+
+ restarted.revoke("lobby-1");
+ HttpConnectionCode revokedPending = restarted.createConnectionCode("lobby-1",
+ URI.create("https://localhost:8443/"), Duration.ofMinutes(5));
+ restarted.revoke("lobby-1");
+ HttpEnrollmentAuthority afterRevocation = new HttpEnrollmentAuthority(identity, state);
+ assertThrows(IllegalArgumentException.class,
+ () -> afterRevocation.enroll("lobby-1", revokedPending.enrollmentToken()));
+ }
+
+ @Test
+ void authorityStatePrunesRevocationsAndBoundsActiveBindings() throws Exception {
+ Path proxy = directory.resolve("bounded-proxy");
+ Path state = directory.resolve("bounded-state");
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(proxy, "localhost");
+ Files.createDirectories(state);
+ java.util.Properties properties = new java.util.Properties();
+ properties.setProperty("version", "3");
+ String firstServer = boundedServerId(0);
+ for (int index = 0; index < 128; index++) {
+ String serverId = boundedServerId(index);
+ String encodedServer = java.util.Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(serverId.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ properties.setProperty("binding." + encodedServer, pin('a') + ":-:0");
+ byte[] hash = new byte[32];
+ java.nio.ByteBuffer.wrap(hash).putInt(index);
+ properties.setProperty("enrollment." + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(hash),
+ Instant.parse("2099-01-01T00:00:00Z").toEpochMilli() + ":" + java.util.Base64.getUrlEncoder()
+ .withoutPadding().encodeToString(firstServer.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ }
+ Path stateFile = state.resolve("http-transport-clients.properties");
+ try (var output = Files.newOutputStream(stateFile)) { properties.store(output, "bounded authority state"); }
+ assertTrue(Files.size(stateFile) < 65536, "the maximum supported state must fit the read bound");
+
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, state);
+ authority.revoke(firstServer);
+ assertFalse(Files.readString(stateFile).contains("binding." + java.util.Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(firstServer.getBytes(java.nio.charset.StandardCharsets.UTF_8))),
+ "revoked bindings must not accumulate in durable state");
+ HttpEnrollmentAuthority restarted = new HttpEnrollmentAuthority(identity, state);
+ HttpConnectionCode replacement = restarted.createConnectionCode("replacement", URI.create("https://localhost:8443/"),
+ Duration.ofMinutes(5));
+ restarted.enroll("replacement", replacement.enrollmentToken());
+ HttpConnectionCode overflow = restarted.createConnectionCode("overflow", URI.create("https://localhost:8443/"),
+ Duration.ofMinutes(5));
+ assertThrows(IllegalStateException.class, () -> restarted.enroll("overflow", overflow.enrollmentToken()));
+ restarted.revoke(boundedServerId(1));
+ assertDoesNotThrow(() -> restarted.enroll("overflow", overflow.enrollmentToken()),
+ "a capacity rejection must not consume the enrollment token");
+ assertTrue(Files.size(stateFile) <= 65536);
+ assertDoesNotThrow(() -> new HttpEnrollmentAuthority(identity, state));
+ }
+
+ @Test
+ void renewalKeepsOldCredentialUntilReplacementAuthenticates() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.resolve("state"));
+ HttpConnectionCode code = authority.createConnectionCode("lobby-1", URI.create("https://localhost:8443/"), Duration.ofMinutes(5));
+ HttpTlsIdentity.IssuedClientCertificate original = authority.enroll("lobby-1", code.enrollmentToken());
+ HttpTlsIdentity.IssuedClientCertificate replacement = authority.renew("lobby-1", original.certificate());
+
+ assertTrue(authority.authenticate("lobby-1", original.certificate()), "lost renewal responses must leave the old credential usable");
+ assertTrue(authority.authenticate("lobby-1", replacement.certificate()), "first replacement request promotes the pending binding");
+ assertFalse(authority.authenticate("lobby-1", original.certificate()), "promotion revokes the superseded credential");
+ assertTrue(new HttpEnrollmentAuthority(identity, directory.resolve("state"))
+ .authenticate("lobby-1", replacement.certificate()), "promoted renewal must survive restart");
+ }
+
+ @Test
+ void serverLeafRotatesInsideRenewalWindowAndPreservesAuthority() throws Exception {
+ Instant now = Instant.now();
+ HttpTlsIdentity original = HttpTlsIdentity.loadOrCreate(directory, "localhost",
+ Clock.fixed(now.minus(Duration.ofDays(340)), ZoneOffset.UTC));
+ String originalPin = HttpTransportSecrets.certificatePin(original.serverCertificate());
+ HttpTlsIdentity renewed = HttpTlsIdentity.loadOrCreate(directory, "localhost", Clock.fixed(now, ZoneOffset.UTC));
+ assertNotEquals(originalPin, renewed.serverCertificatePin());
+ assertEquals(original.caCertificatePin(), renewed.caCertificatePin());
+ assertFalse(HttpTlsIdentity.needsRenewal(renewed.serverCertificate(), Clock.fixed(now, ZoneOffset.UTC)));
+ }
+
+ @Test
+ void runningPrivateCaRollsOverBeforeExpiryWithoutStrandingExistingClients() throws Exception {
+ Instant now = Instant.now();
+ Clock originalClock = Clock.fixed(now.minus(Duration.ofDays(9 * 365L + 30L)), ZoneOffset.UTC);
+ HttpTlsIdentity original = HttpTlsIdentity.loadOrCreate(directory, "localhost", originalClock);
+ X509Certificate originalCa = original.caCertificate();
+ HttpTlsIdentity.IssuedClientCertificate existingClient = original.issueClientCertificate("lobby-1", now);
+ Path client = directory.resolve("client");
+ HttpConnectionCode oldCode = new HttpConnectionCode("lobby-1", URI.create("https://localhost:8443/"),
+ HttpTransportSecrets.certificatePin(original.serverCertificate()), HttpTransportSecrets.certificatePin(originalCa),
+ now.plusSeconds(60), "A".repeat(43));
+ HttpClientCredentialStore.saveEnrolled(client, oldCode, existingClient);
+
+ String renewedPin = original.caCertificatePin();
+ assertNotEquals(HttpTransportSecrets.certificatePin(originalCa), renewedPin);
+ assertEquals(originalCa.getPublicKey(), original.caCertificate().getPublicKey(),
+ "certificate rollover keeps the private authority key so old and new trust anchors overlap");
+ assertFalse(HttpTlsIdentity.needsCaRenewal(original.caCertificate(), Clock.fixed(now, ZoneOffset.UTC)));
+ assertTrue(original.validClientCertificate("lobby-1", existingClient.certificate()));
+
+ X509TrustManager oldClientTrust = Arrays.stream(HttpTlsIdentity.trustManagers(originalCa))
+ .filter(X509TrustManager.class::isInstance).map(X509TrustManager.class::cast).findFirst().orElseThrow();
+ assertDoesNotThrow(() -> oldClientTrust.checkServerTrusted(
+ new X509Certificate[] { original.serverCertificate(), original.caCertificate() }, "ECDHE_ECDSA"));
+ HttpClientCredentialStore.StagedCredential staged = HttpClientCredentialStore.stageReplacement(client,
+ original.issueClientCertificate("lobby-1", now));
+ assertEquals(HttpTransportSecrets.certificatePin(originalCa), HttpClientCredentialStore.loadProfile(client).caCertificatePin());
+ assertEquals(renewedPin, staged.profile().caCertificatePin());
+ HttpClientCredentialStore.activateReplacement(client, staged);
+ assertEquals(renewedPin, HttpClientCredentialStore.loadProfile(client).caCertificatePin());
+ assertEquals(renewedPin, HttpTlsIdentity.loadOrCreate(directory, "localhost").caCertificatePin(),
+ "live CA rollover must survive restart");
+ }
+
+ @Test
+ void activeTlsContextRotatesServerLeafInsideRenewalWindow() throws Exception {
+ Instant now = Instant.now();
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory, "localhost",
+ Clock.fixed(now.minus(Duration.ofDays(340)), ZoneOffset.UTC));
+ String expiringPin = HttpTransportSecrets.certificatePin(identity.serverCertificate());
+ identity.serverContext();
+ assertNotEquals(expiringPin, identity.serverCertificatePin());
+ assertFalse(HttpTlsIdentity.needsRenewal(identity.serverCertificate(), Clock.systemUTC()));
+ }
+
+ @Test
+ void serverTlsUsesPrivateCaTrustAndRejectsForeignClients() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ HttpTlsIdentity foreign = HttpTlsIdentity.loadOrCreate(directory.resolve("foreign"), "localhost");
+ X509TrustManager trust = Arrays.stream(HttpTlsIdentity.trustManagers(identity.caCertificate()))
+ .filter(X509TrustManager.class::isInstance).map(X509TrustManager.class::cast).findFirst().orElseThrow();
+ HttpTlsIdentity.IssuedClientCertificate accepted = identity.issueClientCertificate("lobby-1");
+ HttpTlsIdentity.IssuedClientCertificate rejected = foreign.issueClientCertificate("lobby-1");
+ assertDoesNotThrow(() -> trust.checkClientTrusted(
+ new java.security.cert.X509Certificate[] { accepted.certificate(), identity.caCertificate() }, "EC"));
+ assertThrows(java.security.cert.CertificateException.class, () -> trust.checkClientTrusted(
+ new java.security.cert.X509Certificate[] { rejected.certificate(), foreign.caCertificate() }, "EC"));
+ }
+
+ @Test
+ void stagedCredentialDoesNotReplaceActiveGenerationUntilAtomicActivation() throws Exception {
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost");
+ Path client = directory.resolve("client");
+ HttpTlsIdentity.IssuedClientCertificate original = identity.issueClientCertificate("lobby-1");
+ HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://localhost:8443/"),
+ identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "A".repeat(43));
+ HttpClientCredentialStore.saveEnrolled(client, code, original);
+ String originalPin = HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(client).certificate());
+ HttpTlsIdentity.IssuedClientCertificate replacement = identity.issueClientCertificate("lobby-1");
+ HttpClientCredentialStore.StagedCredential staged = HttpClientCredentialStore.stageReplacement(client, replacement);
+ assertEquals(originalPin, HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(client).certificate()));
+ HttpClientCredentialStore.activateReplacement(client, staged);
+ assertNotEquals(originalPin, HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(client).certificate()));
+ assertTrue(HttpClientCredentialStore.matchesEnrollmentCode(client, code),
+ "automatic certificate renewal must retain the consumed-code marker");
+ HttpTlsIdentity.IssuedClientCertificate manuallyReenrolled = identity.issueClientCertificate("lobby-1");
+ HttpClientCredentialStore.saveEnrolled(client, code, manuallyReenrolled);
+ assertEquals(HttpTransportSecrets.certificatePin(manuallyReenrolled.certificate()),
+ HttpTransportSecrets.certificatePin(HttpClientCredentialStore.loadEnrolled(client).credential().certificate()));
+ }
+
+ @Test
+ void restoresCredentialGenerationAfterFailedReenrollment() throws Exception {
+ Path client = directory.resolve("client-rollback");
+ HttpTlsIdentity oldIdentity = HttpTlsIdentity.loadOrCreate(directory.resolve("old-proxy"), "old.example.test");
+ HttpConnectionCode oldCode = new HttpConnectionCode("lobby-1", URI.create("https://old.example.test:1297/"),
+ oldIdentity.serverCertificatePin(), oldIdentity.caCertificatePin(), Instant.now().plusSeconds(60),
+ "R".repeat(43));
+ HttpClientCredentialStore.saveEnrolled(client, oldCode, oldIdentity.issueClientCertificate("lobby-1"));
+ HttpClientCredentialStore.ActiveCredentialGeneration previous =
+ HttpClientCredentialStore.snapshotActiveGeneration(client);
+
+ HttpTlsIdentity replacementIdentity = HttpTlsIdentity.loadOrCreate(directory.resolve("new-proxy"), "new.example.test");
+ HttpConnectionCode replacementCode = new HttpConnectionCode("lobby-1", URI.create("https://new.example.test:1297/"),
+ replacementIdentity.serverCertificatePin(), replacementIdentity.caCertificatePin(),
+ Instant.now().plusSeconds(60), "S".repeat(43));
+ HttpClientCredentialStore.saveEnrolled(client, replacementCode,
+ replacementIdentity.issueClientCertificate("lobby-1"));
+ assertEquals(replacementCode.endpoint(), HttpClientCredentialStore.loadProfile(client).endpoint());
+
+ HttpClientCredentialStore.restoreActiveGeneration(client, previous);
+ assertEquals(oldCode.endpoint(), HttpClientCredentialStore.loadProfile(client).endpoint());
+ assertTrue(HttpClientCredentialStore.matchesEnrollmentCode(client, oldCode));
+ }
+
+ @Test
+ void rollbackRetainsNewerCredentialForTheSameEndpoint() throws Exception {
+ Path client = directory.resolve("client-renewal-rollback");
+ HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("renewal-proxy"), "renew.example.test");
+ HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://renew.example.test:1297/"),
+ identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60),
+ "T".repeat(43));
+ HttpTlsIdentity.IssuedClientCertificate original = identity.issueClientCertificate("lobby-1");
+ HttpClientCredentialStore.saveEnrolled(client, code, original);
+ HttpClientCredentialStore.ActiveCredentialGeneration previous =
+ HttpClientCredentialStore.snapshotActiveGeneration(client);
+
+ HttpConnectionCode replacementCode = new HttpConnectionCode("lobby-1", code.endpoint(),
+ identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60),
+ "U".repeat(43));
+ HttpTlsIdentity.IssuedClientCertificate renewed = identity.issueClientCertificate("lobby-1");
+ HttpClientCredentialStore.saveEnrolled(client, replacementCode, renewed);
+ HttpClientCredentialStore.restoreActiveGenerationAfterReplacement(client, previous);
+
+ assertEquals(HttpTransportSecrets.certificatePin(renewed.certificate()),
+ HttpTransportSecrets.certificatePin(HttpClientCredentialStore.load(client).certificate()),
+ "rollback must not reactivate a same-endpoint certificate that renewal may have revoked");
+ assertTrue(HttpClientCredentialStore.matchesEnrollmentCode(client, code),
+ "the retained credential must recognize the connection code restored in YAML");
+ }
+
+ private static String pin(char character) { return String.valueOf(character).repeat(64); }
+ private static String boundedServerId(int index) { return String.format("s%03d", index) + "x".repeat(60); }
+}