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); } +}