diff --git a/examples/secret-provider/README.md b/examples/secret-provider/README.md
new file mode 100644
index 00000000..9696e4f8
--- /dev/null
+++ b/examples/secret-provider/README.md
@@ -0,0 +1,43 @@
+# Context-owned Secret Provider example
+
+Run `bash examples/secret-provider/verify.sh`. The runtime-owned test binds a
+symbolic reference to an owner-only local file at trusted startup and resolves
+it through `UserContext` with a comment. Changing the file is visible at the
+next resolution.
+
+A second test installs a fixed Vault KV v2 path and separate bootstrap token
+provider, then uses the real HTTP adapter against a loopback fixture. It checks
+the path, token header, response version, and a stalled-response timeout. Both
+tests run twice, and the verifier rejects secret canaries in output. This is a
+controlled fixture, not a production Vault deployment.
+
+Private-CA rollover is explicit: `trustedRootsPem(List.of(oldPem, newPem))`
+temporarily trusts both roots for this named HTTPS profile, replacing system
+roots rather than expanding process-wide trust. A later profile with only
+`trustedRootPem(newPem)` retires the old CA for new requests. The TLS example
+checks reads through both CA-signed server certificates during overlap and
+rejects the old certificate after retirement. Each list element contains one
+PEM certificate; the bounded profile accepts at most four roots. In-flight
+requests already holding the old HTTP service may finish within their existing
+budget after the Context profile is replaced; replacement is not retroactive
+revocation. The example pauses one accepted TLS request, retires its CA for
+new calls, proves a second call sends no HTTP request, then lets the first
+finish. Production certificate rollout still requires traffic coordination and
+draining; do not discard the old CA before all instances have the overlap set.
+The controlled two-instance fixture keeps old- and new-CA servers online
+together, advances independent Contexts through mixed and overlap profiles,
+rejects an old endpoint after trust retirement, then restores the old
+instance by routing it to the new endpoint. It checks exact server-side HTTP
+request counts; it is not a separate-host production rollout.
+The process-rollover fixture additionally starts separate JVM clients against
+both live certificate endpoints. The old route is moved to the new endpoint
+while overlap trust is still active, before new-only trust is installed.
+After retirement, a deliberately stale old route is rejected before HTTP.
+The servers observe two old and five new HTTP requests, and each child process
+must report its own PID and a canary-free result. This is loopback process
+isolation, not multi-host traffic management.
+The example also accepts an old-CA Vault read but deliberately never returns
+its response. The read ends within its 900 ms whole-call budget as
+`UNAVAILABLE`, without a second HTTP request; only then is the Context switched
+to new-only trust. This is a bounded-drain test, not proof that changing trust
+immediately cancels in-flight requests.
diff --git a/examples/secret-provider/pom.xml b/examples/secret-provider/pom.xml
new file mode 100644
index 00000000..e9480136
--- /dev/null
+++ b/examples/secret-provider/pom.xml
@@ -0,0 +1,11 @@
+
+ 4.0.0
+ io.teaqlteaql-java-parent1.555-RELEASE../../pom.xml
+ teaql-secret-provider-example
+
+ io.teaqlteaql-runtime
+ io.teaqlteaql-tool-http
+ io.teaqlteaql-opentelemetry${project.version}test
+ junitjunittest
+
+
diff --git a/examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretOpenObserveProbeTest.java b/examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretOpenObserveProbeTest.java
new file mode 100644
index 00000000..68f93105
--- /dev/null
+++ b/examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretOpenObserveProbeTest.java
@@ -0,0 +1,112 @@
+package io.teaql.examples.secret;
+
+import static org.junit.Assert.*;
+import static org.junit.Assume.assumeTrue;
+
+import com.sun.net.httpserver.HttpServer;
+import io.teaql.core.http.HttpRequestService;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.secret.SecretFailure;
+import io.teaql.core.secret.SecretReference;
+import io.teaql.core.secret.SecretValue;
+import io.teaql.opentelemetry.StandardOpenTelemetryFactory;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.TeaQLRuntime;
+import io.teaql.runtime.secret.VaultKv2SecretProvider;
+import io.teaql.tools.http.HttpServiceProfile;
+import io.teaql.tools.http.JdkHttpRequestService;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Test;
+
+/** Opt-in Vault -> standard OTLP probe; backend readback is checked by conformance. */
+public class SecretOpenObserveProbeTest {
+ private static final String TOKEN = "PRIVATE_VAULT_TOKEN_CANARY";
+ private static final String VALUE = "PRIVATE_VAULT_VALUE_CANARY";
+
+ @Test public void exportedVaultTraceDoesNotContainCredentialOrResponseValue() throws Exception {
+ assumeTrue("true".equals(System.getenv("TEAQL_SECRET_LIVE_OTLP")));
+ URI endpoint = URI.create(System.getenv("TEAQL_TELEMETRY_OTLP_ENDPOINT"));
+ assertEquals("127.0.0.1", endpoint.getHost());
+ String serviceName = System.getenv("TEAQL_TELEMETRY_SERVICE_NAME");
+ assertNotNull(serviceName);
+ String outcome = System.getenv().getOrDefault("TEAQL_SECRET_PROBE_OUTCOME", "success");
+ assertTrue("unsupported probe outcome", List.of("success", "denied", "missing",
+ "malformed", "expired", "timeout").contains(outcome));
+ var telemetry = new StandardOpenTelemetryFactory().create(serviceName, endpoint);
+ var carrier = new AtomicReference();
+ HttpServer server = HttpServer.create(new InetSocketAddress(
+ InetAddress.getByAddress(new byte[] {127, 0, 0, 1}), 0), 0);
+ server.createContext("/v1/secret/data/payment/api", exchange -> {
+ assertEquals(TOKEN, exchange.getRequestHeaders().getFirst("X-Vault-Token"));
+ carrier.set(exchange.getRequestHeaders().getFirst("traceparent"));
+ if (outcome.equals("timeout")) {
+ try {
+ Thread.sleep(1800);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ } finally {
+ exchange.close();
+ }
+ return;
+ }
+ String response = switch (outcome) {
+ case "denied", "missing" -> "{\"errors\":[\"" + VALUE + "\"]}";
+ case "malformed" -> "{\"data\":\"" + VALUE + "\",";
+ case "expired" -> "{\"data\":{\"data\":{\"value\":\"" + VALUE
+ + "\"},\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"2020-01-01T00:00:00Z\"}}}";
+ default -> "{\"data\":{\"data\":{\"value\":\"" + VALUE
+ + "\"},\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"\"}}}";
+ };
+ byte[] body = response.getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(outcome.equals("denied") ? 403 : outcome.equals("missing") ? 404 : 200, body.length);
+ try (var output = exchange.getResponseBody()) { output.write(body); }
+ });
+ server.start();
+ try {
+ var runtime = TeaQLRuntime.builder().metadata(new SimpleEntityMetaFactory())
+ .telemetry(telemetry).build();
+ var reference = new SecretReference("payment", "api_key");
+ var provider = VaultKv2SecretProvider.builder("vault", "secret",
+ (caller, ignored) -> new SecretValue(TOKEN.getBytes(StandardCharsets.UTF_8), null, null),
+ new SecretReference("bootstrap", "vault_token"))
+ .bind(reference, "payment/api", "value").build();
+ var context = new DefaultUserContext(runtime).withSecretProvider(provider);
+ var profileBuilder = HttpServiceProfile.builder("vault",
+ URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"));
+ if (outcome.equals("timeout")) {
+ profileBuilder.budgets(Duration.ofSeconds(2), Duration.ofMillis(700),
+ Duration.ofSeconds(3));
+ }
+ var profile = profileBuilder.build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(profile)));
+ if (outcome.equals("success")) {
+ assertEquals(VALUE, new String(context.resolveSecret(reference, "verify Vault adapter")
+ .exposeBytes(), StandardCharsets.UTF_8));
+ } else {
+ SecretFailure.Code expected = switch (outcome) {
+ case "denied" -> SecretFailure.Code.DENIED;
+ case "missing" -> SecretFailure.Code.MISSING;
+ case "expired" -> SecretFailure.Code.EXPIRED;
+ default -> SecretFailure.Code.UNAVAILABLE;
+ };
+ assertEquals(expected, assertThrows(SecretFailure.class,
+ () -> context.resolveSecret(reference, "verify Vault adapter")).code());
+ }
+ assertNotNull(carrier.get());
+ assertTrue(carrier.get().startsWith("00-") && carrier.get().length() == 55);
+ telemetry.flush();
+ System.out.println("SECRET_OTLP_PROBE_PASS outcome=" + outcome
+ + " traceparent=" + carrier.get());
+ } finally {
+ server.stop(0);
+ telemetry.shutdown();
+ }
+ }
+}
diff --git a/examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretProviderExampleTest.java b/examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretProviderExampleTest.java
new file mode 100644
index 00000000..6c05a890
--- /dev/null
+++ b/examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretProviderExampleTest.java
@@ -0,0 +1,157 @@
+package io.teaql.examples.secret;
+
+import io.teaql.core.secret.SecretFailure;
+import io.teaql.core.secret.SecretProvider;
+import io.teaql.core.secret.SecretReference;
+import io.teaql.core.secret.SecretValue;
+import io.teaql.core.businessid.BusinessClock;
+import io.teaql.core.BaseEntity;
+import io.teaql.core.EntityKey;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.secret.BoundSecretProvider;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import static org.junit.Assert.*;
+
+public class SecretProviderExampleTest {
+ @Rule public TemporaryFolder temporary = new TemporaryFolder();
+
+ private static final class LedgerProbe extends BaseEntity {
+ private String name = "original";
+
+ private LedgerProbe(long id) {
+ updateId(id);
+ }
+
+ private void updateName(String replacement) {
+ handleUpdate("name", name, replacement);
+ name = replacement;
+ }
+ }
+
+ @Test public void startupBindingAndContextResolutionObserveRotation() throws Exception {
+ Path root = temporary.newFolder("keys").toPath();
+ org.junit.Assume.assumeTrue(Files.getFileStore(root).supportsFileAttributeView("posix"));
+ Files.setPosixFilePermissions(root, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE));
+ Path file = root.resolve("signing-key");
+ Files.writeString(file, "private-example-key-v1");
+ Files.setPosixFilePermissions(file, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE));
+ SecretReference reference = new SecretReference("document", "signing");
+ BoundSecretProvider provider = BoundSecretProvider.builder(root)
+ .file(reference, Path.of("signing-key"), "v1", null).build();
+ DefaultUserContext context = new DefaultUserContext(null).withSecretProvider(provider);
+ Object marker = new Object();
+ BusinessClock clock = ignored -> LocalDate.of(2000, 1, 1);
+ context.putAttribute("example.rotation-marker", marker);
+ context.putAttribute(BusinessClock.class.getName(), clock);
+ LedgerProbe pending = new LedgerProbe(42);
+ pending.updateName("pending before rotation");
+ var ledger = pending.getEntityMutationLedger();
+ EntityKey pendingKey = new EntityKey(pending.typeName(), 42L);
+
+ assertEquals("private-example-key-v1", new String(
+ context.resolveSecret(reference, "sign document").exposeBytes(), StandardCharsets.UTF_8));
+ Files.writeString(file, "private-example-key-v2");
+ context.withSecretProvider(BoundSecretProvider.builder(root)
+ .file(reference, Path.of("signing-key"), "v2", null).build());
+ var rotated = context.resolveSecret(reference, "sign next document");
+ assertEquals("private-example-key-v2", new String(rotated.exposeBytes(), StandardCharsets.UTF_8));
+ assertEquals("v2", rotated.version());
+ assertSame(marker, context.getAttribute("example.rotation-marker"));
+ assertSame(clock, context.getAttribute(BusinessClock.class.getName()));
+ assertSame(ledger, pending.getEntityMutationLedger());
+ assertEquals("pending before rotation", ledger.get(pendingKey, "name"));
+ assertTrue(pending.dirtyFields().contains("name"));
+ assertFalse(rotated.toString().contains("private-example-key"));
+ assertEquals(SecretFailure.Code.INVALID_INPUT,
+ assertThrows(SecretFailure.class,
+ () -> context.resolveSecret(reference, " ")).code());
+ System.out.println("SECRET_ROTATION_LEDGER_UNCHANGED_PASS language=java context=1 clock=1 pending=1");
+ }
+
+ @Test public void operationalExpiryAndSharedCustomProviderRemainContextIsolated() throws Exception {
+ SecretReference reference = new SecretReference("document", "signing");
+ Path root = temporary.newFolder("expired-keys").toPath();
+ org.junit.Assume.assumeTrue(Files.getFileStore(root).supportsFileAttributeView("posix"));
+ Files.setPosixFilePermissions(root, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE));
+ Path key = root.resolve("signing-key");
+ Files.writeString(key, "private-example-key");
+ Files.setPosixFilePermissions(key, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE));
+ BoundSecretProvider expired = BoundSecretProvider.builder(root)
+ .file(reference, Path.of("signing-key"), "expired", Instant.EPOCH).build();
+ DefaultUserContext frozen = new DefaultUserContext(null).withSecretProvider(expired);
+ frozen.putAttribute(BusinessClock.class.getName(),
+ (BusinessClock) ignored -> LocalDate.of(1900, 1, 1));
+ assertEquals(SecretFailure.Code.EXPIRED,
+ assertThrows(SecretFailure.class,
+ () -> frozen.resolveSecret(reference, "check operational expiry")).code());
+
+ CountDownLatch entered = new CountDownLatch(2);
+ SecretProvider custom = (caller, requested) -> {
+ entered.countDown();
+ try {
+ if (!entered.await(3, TimeUnit.SECONDS)) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ return new SecretValue(caller.getAttribute("example.business-date").toString()
+ .getBytes(StandardCharsets.UTF_8), null, null);
+ };
+ DefaultUserContext first = new DefaultUserContext(null).withSecretProvider(custom);
+ DefaultUserContext second = new DefaultUserContext(null).withSecretProvider(custom);
+ first.putAttribute("example.business-date", "2000-01-01");
+ second.putAttribute("example.business-date", "2100-01-01");
+ // Same type and ID make accidental process- or provider-owned ledger
+ // sharing observable even when the two calls run concurrently.
+ LedgerProbe firstEntity = new LedgerProbe(42);
+ LedgerProbe secondEntity = new LedgerProbe(42);
+ var executor = Executors.newFixedThreadPool(2);
+ try {
+ var firstCall = executor.submit(() -> {
+ String resolved = new String(first.resolveSecret(reference, "first custom context")
+ .exposeBytes(), StandardCharsets.UTF_8);
+ firstEntity.updateName("first pending");
+ return resolved;
+ });
+ var secondCall = executor.submit(() -> {
+ String resolved = new String(second.resolveSecret(reference, "second custom context")
+ .exposeBytes(), StandardCharsets.UTF_8);
+ secondEntity.updateName("second pending");
+ return resolved;
+ });
+ assertEquals("2000-01-01", firstCall.get(4, TimeUnit.SECONDS));
+ assertEquals("2100-01-01", secondCall.get(4, TimeUnit.SECONDS));
+ } finally {
+ executor.shutdownNow();
+ }
+ assertEquals("2000-01-01", first.getAttribute("example.business-date"));
+ assertEquals("2100-01-01", second.getAttribute("example.business-date"));
+ EntityKey ledgerKey = new EntityKey(firstEntity.typeName(), 42L);
+ assertNotSame(firstEntity.getEntityMutationLedger(), secondEntity.getEntityMutationLedger());
+ assertEquals("first pending", firstEntity.getEntityMutationLedger().get(ledgerKey, "name"));
+ assertEquals("second pending", secondEntity.getEntityMutationLedger().get(ledgerKey, "name"));
+ firstEntity.getEntityMutationLedger().clearCurrentChangeSet();
+ assertNull(firstEntity.getEntityMutationLedger().get(ledgerKey, "name"));
+ assertEquals("second pending", secondEntity.getEntityMutationLedger().get(ledgerKey, "name"));
+ assertTrue(secondEntity.dirtyFields().contains("name"));
+ System.out.println("SECRET_CONTEXT_LEDGER_ISOLATION_PASS language=java overlap=1 same_id=1 clear_isolated=1");
+ }
+}
diff --git a/examples/secret-provider/src/test/java/io/teaql/examples/secret/VaultKv2ExampleTest.java b/examples/secret-provider/src/test/java/io/teaql/examples/secret/VaultKv2ExampleTest.java
new file mode 100644
index 00000000..153462e2
--- /dev/null
+++ b/examples/secret-provider/src/test/java/io/teaql/examples/secret/VaultKv2ExampleTest.java
@@ -0,0 +1,936 @@
+package io.teaql.examples.secret;
+
+import io.teaql.core.http.HttpRequestService;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.secret.SecretFailure;
+import io.teaql.core.secret.SecretProvider;
+import io.teaql.core.secret.SecretReference;
+import io.teaql.core.secret.SecretValue;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.RuntimeTelemetry;
+import io.teaql.runtime.TeaQLRuntime;
+import io.teaql.runtime.secret.BoundSecretProvider;
+import io.teaql.runtime.secret.VaultKv2SecretProvider;
+import io.teaql.tools.http.HttpServiceProfile;
+import io.teaql.tools.http.JdkHttpRequestService;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.security.KeyFactory;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Handler;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLServerSocket;
+import javax.net.ssl.SSLSocket;
+import org.junit.Test;
+import org.junit.Assume;
+import static org.junit.Assert.*;
+
+public class VaultKv2ExampleTest {
+ private static final SecretReference REFERENCE = new SecretReference("payment", "api_key");
+ private static final byte[] BODY = ("{\"data\":{\"data\":{\"value\":\"PRIVATE-VAULT-VALUE\"},"
+ + "\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"\"}}}")
+ .getBytes(StandardCharsets.UTF_8);
+
+ private DefaultUserContext context(int port, Duration timeout) {
+ var profile = HttpServiceProfile.builder("vault", URI.create("http://127.0.0.1:" + port + "/"))
+ .budgets(timeout, timeout, timeout).maxResponseBytes(64 * 1024).build();
+ var provider = VaultKv2SecretProvider.builder("vault", "secret",
+ (caller, reference) -> new SecretValue(
+ "PRIVATE-VAULT-TOKEN".getBytes(StandardCharsets.UTF_8), null, null),
+ new SecretReference("bootstrap", "vault_token"))
+ .bind(REFERENCE, "payment/api", "value").build();
+ DefaultUserContext context = new DefaultUserContext(null).withSecretProvider(provider);
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(profile)));
+ return context;
+ }
+
+ @Test public void sharedVaultProviderKeepsConcurrentContextRequestsIndependent() throws Exception {
+ CountDownLatch entered = new CountDownLatch(2);
+ List calls = new CopyOnWriteArrayList<>();
+ HttpRequestService transport = new HttpRequestService() {
+ @Override public boolean profileIsEncryptedOrLoopback(String profile) {
+ return "vault".equals(profile);
+ }
+
+ @Override public Response execute(io.teaql.core.UserContext caller, Call call) {
+ calls.add(call);
+ entered.countDown();
+ try {
+ if (!entered.await(3, TimeUnit.SECONDS)) throw new AssertionError("requests did not overlap");
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError("request interrupted", interrupted);
+ }
+ String expected = caller.getAttribute("example.vault-token").toString();
+ assertEquals(expected, call.headers().get("X-Vault-Token"));
+ assertEquals("v1/secret/data/payment/api", call.relativePath());
+ String value = expected.equals("token-context-a") ? "value-a" : "value-b";
+ String json = "{\"data\":{\"data\":{\"value\":\"" + value
+ + "\"},\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"\"}}}";
+ return new Response(200, json.getBytes(StandardCharsets.UTF_8), 1);
+ }
+ };
+ SecretProvider tokenProvider = (caller, ignored) -> new SecretValue(
+ caller.getAttribute("example.vault-token").toString().getBytes(StandardCharsets.UTF_8),
+ null, null);
+ var shared = VaultKv2SecretProvider.builder("vault", "secret", tokenProvider,
+ new SecretReference("bootstrap", "vault_token"))
+ .bind(REFERENCE, "payment/api", "value").build();
+ DefaultUserContext first = new DefaultUserContext(null).withSecretProvider(shared);
+ DefaultUserContext second = new DefaultUserContext(null).withSecretProvider(shared);
+ first.putAttribute(HttpRequestService.class.getName(), transport);
+ second.putAttribute(HttpRequestService.class.getName(), transport);
+ first.putAttribute("example.vault-token", "token-context-a");
+ second.putAttribute("example.vault-token", "token-context-b");
+
+ var executor = Executors.newFixedThreadPool(2);
+ try {
+ var firstCall = executor.submit(() -> first.resolveSecret(REFERENCE, "first Vault context"));
+ var secondCall = executor.submit(() -> second.resolveSecret(REFERENCE, "second Vault context"));
+ assertArrayEquals("value-a".getBytes(StandardCharsets.UTF_8),
+ firstCall.get(4, TimeUnit.SECONDS).exposeBytes());
+ assertArrayEquals("value-b".getBytes(StandardCharsets.UTF_8),
+ secondCall.get(4, TimeUnit.SECONDS).exposeBytes());
+ } finally {
+ executor.shutdownNow();
+ }
+ assertEquals(2, calls.size());
+ assertNotSame(calls.get(0), calls.get(1));
+ assertEquals(Set.of("token-context-a", "token-context-b"),
+ calls.stream().map(call -> call.headers().get("X-Vault-Token"))
+ .collect(java.util.stream.Collectors.toSet()));
+ }
+
+ private DefaultUserContext tlsContext(int port, byte[] trustedRoot, List captured) {
+ SecretProvider bootstrap = (caller, reference) -> new SecretValue(
+ "PRIVATE-VAULT-TOKEN".getBytes(StandardCharsets.UTF_8), null, null);
+ return configuredTlsContext(URI.create("https://127.0.0.1:" + port + "/"), trustedRoot,
+ bootstrap, "payment/api", captured);
+ }
+
+ private DefaultUserContext configuredTlsContext(URI endpoint, byte[] trustedRoot,
+ SecretProvider bootstrap, String path,
+ List captured) {
+ var builder = HttpServiceProfile.builder("vault", endpoint)
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8));
+ if (trustedRoot != null) builder.trustedRootPem(trustedRoot);
+ var provider = VaultKv2SecretProvider.builder("vault", "secret", bootstrap,
+ new SecretReference("bootstrap", "vault_token"))
+ .bind(REFERENCE, path, "value").build();
+ RuntimeTelemetry telemetry = operation -> {
+ captured.add("start:" + operation);
+ return new RuntimeTelemetry.Scope() {
+ @Override public void success(Map attributes) {
+ captured.add("success:" + attributes);
+ }
+ @Override public void failure(Throwable error) {
+ captured.add("failure:" + error);
+ }
+ @Override public void failureWithAttributes(Throwable error, Map attributes) {
+ captured.add("failure:" + error + ":" + attributes);
+ }
+ };
+ };
+ TeaQLRuntime runtime = TeaQLRuntime.builder().metadata(new SimpleEntityMetaFactory())
+ .telemetry(telemetry).build();
+ DefaultUserContext context = new DefaultUserContext(runtime).withSecretProvider(provider);
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(builder.build())));
+ return context;
+ }
+
+ @Test public void liveVaultKv2OverTlsChecksSuccessDeniedMissingAndUnknownCa() throws Exception {
+ String endpointText = System.getenv("TEAQL_TEST_VAULT_URL");
+ Assume.assumeTrue("live Vault fixture is opt-in", endpointText != null);
+ URI endpoint = URI.create(endpointText);
+ assertEquals("https", endpoint.getScheme());
+ assertEquals("127.0.0.1", endpoint.getHost());
+ byte[] root = Files.readAllBytes(java.nio.file.Path.of(
+ System.getenv("TEAQL_TEST_VAULT_CA_PEM")));
+ String expected = System.getenv("TEAQL_TEST_VAULT_EXPECTED_VALUE");
+ String version = System.getenv("TEAQL_TEST_VAULT_EXPECTED_VERSION");
+ List captured = new CopyOnWriteArrayList<>();
+ var tokenReference = new SecretReference("bootstrap", "vault_token");
+ var token = BoundSecretProvider.builder(null)
+ .environment(tokenReference, "TEAQL_TEST_VAULT_TOKEN", null, null).build();
+ SecretValue value = configuredTlsContext(endpoint, root, token, "payment/api", captured)
+ .resolveSecret(REFERENCE, "verify live Vault credential");
+ assertArrayEquals(expected.getBytes(StandardCharsets.UTF_8), value.exposeBytes());
+ assertEquals("vault-kv2:" + version, value.version());
+
+ var wrongToken = BoundSecretProvider.builder(null)
+ .environment(tokenReference, "TEAQL_TEST_VAULT_BAD_TOKEN", null, null).build();
+ SecretFailure denied = assertThrows(SecretFailure.class,
+ () -> configuredTlsContext(endpoint, root, wrongToken, "payment/api", captured)
+ .resolveSecret(REFERENCE, "verify denied Vault credential"));
+ assertEquals(SecretFailure.Code.DENIED, denied.code());
+ SecretFailure missing = assertThrows(SecretFailure.class,
+ () -> configuredTlsContext(endpoint, root, token, "payment/absent", captured)
+ .resolveSecret(REFERENCE, "verify missing Vault credential"));
+ assertEquals(SecretFailure.Code.MISSING, missing.code());
+ SecretFailure untrusted = assertThrows(SecretFailure.class,
+ () -> configuredTlsContext(endpoint, null, token, "payment/api", captured)
+ .resolveSecret(REFERENCE, "verify untrusted Vault certificate"));
+ assertEquals(SecretFailure.Code.UNAVAILABLE, untrusted.code());
+ assertNull(untrusted.getCause());
+
+ String diagnostics = captured.toString();
+ assertTrue(diagnostics.contains("success:"));
+ assertTrue(diagnostics.contains("failure:"));
+ assertFalse(diagnostics.contains(System.getenv("TEAQL_TEST_VAULT_TOKEN")));
+ assertFalse(diagnostics.contains(System.getenv("TEAQL_TEST_VAULT_BAD_TOKEN")));
+ assertFalse(diagnostics.contains(expected));
+ System.out.println("VAULT_LIVE_PASS success=1 denied=1 missing=1 unknown_ca=1 diagnostics=1");
+ }
+
+ private static void openssl(String... arguments) throws Exception {
+ String[] command = new String[arguments.length + 1];
+ command[0] = "openssl";
+ System.arraycopy(arguments, 0, command, 1, arguments.length);
+ Process process = new ProcessBuilder(command).redirectErrorStream(true)
+ .redirectOutput(ProcessBuilder.Redirect.DISCARD).start();
+ assertEquals("local TLS fixture generation failed", 0, process.waitFor());
+ }
+
+ @Test public void trustedTlsVaultSucceedsAndUntrustedCertificateSendsNoRequest() throws Exception {
+ List captured = new CopyOnWriteArrayList<>();
+ List logs = new CopyOnWriteArrayList<>();
+ Logger logger = Logger.getLogger(JdkHttpRequestService.class.getName());
+ Handler handler = new Handler() {
+ @Override public void publish(LogRecord record) { logs.add(record.getMessage()); }
+ @Override public void flush() {}
+ @Override public void close() {}
+ };
+ logger.addHandler(handler);
+ var directory = Files.createTempDirectory("teaql-vault-tls-");
+ var rootPath = directory.resolve("root.pem");
+ var rootKeyPath = directory.resolve("root.key");
+ var nextRootPath = directory.resolve("next-root.pem");
+ var nextRootKeyPath = directory.resolve("next-root.key");
+ var requestPath = directory.resolve("server.csr");
+ var serialPath = directory.resolve("root.srl");
+ var nextSerialPath = directory.resolve("next-root.srl");
+ var certificatePath = directory.resolve("server.pem");
+ var nextCertificatePath = directory.resolve("next-server.pem");
+ var keyPath = directory.resolve("server.key");
+ try {
+ openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
+ "-subj", "/CN=TeaQL test root", "-addext", "basicConstraints=critical,CA:TRUE",
+ "-addext", "keyUsage=critical,keyCertSign,cRLSign",
+ "-keyout", rootKeyPath.toString(), "-out", rootPath.toString());
+ openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
+ "-subj", "/CN=TeaQL next test root", "-addext", "basicConstraints=critical,CA:TRUE",
+ "-addext", "keyUsage=critical,keyCertSign,cRLSign",
+ "-keyout", nextRootKeyPath.toString(), "-out", nextRootPath.toString());
+ openssl("req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=127.0.0.1",
+ "-addext", "subjectAltName=IP:127.0.0.1",
+ "-keyout", keyPath.toString(), "-out", requestPath.toString());
+ openssl("x509", "-req", "-days", "1", "-CAcreateserial", "-copy_extensions", "copy",
+ "-in", requestPath.toString(), "-CA", rootPath.toString(),
+ "-CAkey", rootKeyPath.toString(), "-out", certificatePath.toString());
+ openssl("x509", "-req", "-days", "1", "-CAcreateserial", "-copy_extensions", "copy",
+ "-in", requestPath.toString(), "-CA", nextRootPath.toString(),
+ "-CAkey", nextRootKeyPath.toString(), "-out", nextCertificatePath.toString());
+ byte[] rootPem = Files.readAllBytes(rootPath);
+ byte[] nextRootPem = Files.readAllBytes(nextRootPath);
+ URI configuredEndpoint = URI.create("https://127.0.0.1:8443/");
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpServiceProfile.builder("vault", configuredEndpoint)
+ .trustedRootsPem(List.of(rootPem, rootPem)));
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpServiceProfile.builder("vault", configuredEndpoint)
+ .trustedRootsPem(List.of(rootPem, nextRootPem, rootPem,
+ nextRootPem, rootPem)));
+ byte[] bundledRoots = new byte[rootPem.length + nextRootPem.length];
+ System.arraycopy(rootPem, 0, bundledRoots, 0, rootPem.length);
+ System.arraycopy(nextRootPem, 0, bundledRoots, rootPem.length, nextRootPem.length);
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpServiceProfile.builder("vault", configuredEndpoint)
+ .trustedRootPem(bundledRoots));
+ Certificate certificate;
+ Certificate root;
+ Certificate nextCertificate;
+ Certificate nextRoot;
+ try (var source = Files.newInputStream(certificatePath)) {
+ certificate = CertificateFactory.getInstance("X.509").generateCertificate(source);
+ }
+ try (var source = Files.newInputStream(rootPath)) {
+ root = CertificateFactory.getInstance("X.509").generateCertificate(source);
+ }
+ try (var source = Files.newInputStream(nextCertificatePath)) {
+ nextCertificate = CertificateFactory.getInstance("X.509").generateCertificate(source);
+ }
+ try (var source = Files.newInputStream(nextRootPath)) {
+ nextRoot = CertificateFactory.getInstance("X.509").generateCertificate(source);
+ }
+ String keyText = Files.readString(keyPath);
+ String encoded = keyText.replace("-----BEGIN PRIVATE KEY-----", "")
+ .replace("-----END PRIVATE KEY-----", "").replaceAll("\\s", "");
+ PrivateKey key = KeyFactory.getInstance("RSA").generatePrivate(
+ new PKCS8EncodedKeySpec(Base64.getDecoder().decode(encoded)));
+ KeyStore store = KeyStore.getInstance("PKCS12");
+ store.load(null, null);
+ char[] password = "fixture-only".toCharArray();
+ store.setKeyEntry("server", key, password, new Certificate[] {certificate, root});
+ KeyManagerFactory managers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ managers.init(store, password);
+ SSLContext tls = SSLContext.getInstance("TLS");
+ tls.init(managers.getKeyManagers(), null, null);
+ KeyStore nextStore = KeyStore.getInstance("PKCS12");
+ nextStore.load(null, null);
+ nextStore.setKeyEntry("server", key, password, new Certificate[] {nextCertificate, nextRoot});
+ KeyManagerFactory nextManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ nextManagers.init(nextStore, password);
+ SSLContext nextTls = SSLContext.getInstance("TLS");
+ nextTls.init(nextManagers.getKeyManagers(), null, null);
+
+ var executor = Executors.newCachedThreadPool();
+ AtomicInteger receivedRequests = new AtomicInteger();
+ AtomicInteger acceptedConnections = new AtomicInteger();
+ List fixtureEvents = new CopyOnWriteArrayList<>();
+ try (SSLServerSocket server = (SSLServerSocket) tls.getServerSocketFactory()
+ .createServerSocket(0, 2, InetAddress.getLoopbackAddress())) {
+ server.setSoTimeout(250);
+ CompletableFuture served = new CompletableFuture<>();
+ var acceptor = executor.submit(() -> {
+ while (!served.isDone()) {
+ try {
+ SSLSocket socket = (SSLSocket) server.accept();
+ int connection = acceptedConnections.incrementAndGet();
+ fixtureEvents.add("accepted");
+ executor.execute(() -> {
+ try (socket) {
+ socket.setSoTimeout(8000);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(
+ socket.getInputStream(), StandardCharsets.US_ASCII));
+ String firstLine = reader.readLine();
+ if (firstLine == null) {
+ fixtureEvents.add("closed-before-http");
+ return;
+ }
+ fixtureEvents.add("received-http");
+ receivedRequests.incrementAndGet();
+ assertTrue(firstLine.startsWith("GET /v1/secret/data/payment/api HTTP/1.1"));
+ boolean tokenSeen = false;
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {
+ if (line.toLowerCase(java.util.Locale.ROOT).startsWith("x-vault-token:")) {
+ tokenSeen = line.trim().endsWith("PRIVATE-VAULT-TOKEN");
+ }
+ }
+ assertTrue(tokenSeen);
+ socket.getOutputStream().write(("HTTP/1.1 200 OK\r\nContent-Length: "
+ + BODY.length + "\r\nConnection: close\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII));
+ socket.getOutputStream().write(BODY);
+ fixtureEvents.add("responded");
+ } catch (javax.net.ssl.SSLException | java.net.SocketTimeoutException
+ | java.net.SocketException incompleteHandshake) {
+ fixtureEvents.add("incomplete-handshake:" + incompleteHandshake.getClass().getSimpleName());
+ // Failed TLS connections may still occupy a worker, but
+ // cannot block acceptance of the trusted connection.
+ if (connection == 4) served.complete(null);
+ } catch (Throwable error) {
+ fixtureEvents.add("fixture-error:" + error.getClass().getSimpleName());
+ served.completeExceptionally(error);
+ }
+ });
+ } catch (java.net.SocketTimeoutException awaitingConnection) {
+ // Recheck whether the trusted request has finished.
+ } catch (Throwable error) {
+ served.completeExceptionally(error);
+ }
+ }
+ return null;
+ });
+ DefaultUserContext context = tlsContext(server.getLocalPort(), null, captured);
+ SecretFailure untrusted = assertThrows(SecretFailure.class,
+ () -> context.resolveSecret(REFERENCE, "read configured credential"));
+ assertEquals(SecretFailure.Code.UNAVAILABLE, untrusted.code());
+ assertNull(untrusted.getCause());
+ assertEquals("untrusted peer sent an HTTP request", 0, receivedRequests.get());
+ var trustedProfile = HttpServiceProfile.builder("vault",
+ URI.create("https://127.0.0.1:" + server.getLocalPort() + "/"))
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootPem(rootPem).build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(trustedProfile)));
+ SecretValue value;
+ try {
+ value = context.resolveSecret(REFERENCE, "read configured credential");
+ } catch (SecretFailure failure) {
+ throw new AssertionError("trusted TLS failed; fixture=" + fixtureEvents, failure);
+ }
+ assertEquals("PRIVATE-VAULT-VALUE", new String(value.exposeBytes(), StandardCharsets.UTF_8));
+ assertEquals("vault-kv2:7", value.version());
+ var overlapProfile = HttpServiceProfile.builder("vault",
+ URI.create("https://127.0.0.1:" + server.getLocalPort() + "/"))
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootsPem(List.of(rootPem, nextRootPem)).build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(overlapProfile)));
+ SecretValue overlap = context.resolveSecret(REFERENCE, "read during TLS root overlap");
+ assertEquals("vault-kv2:7", overlap.version());
+ var removedTrustProfile = HttpServiceProfile.builder("vault",
+ URI.create("https://127.0.0.1:" + server.getLocalPort() + "/"))
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootPem(nextRootPem)
+ .build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(removedTrustProfile)));
+ SecretFailure trustRemoved = assertThrows(SecretFailure.class,
+ () -> context.resolveSecret(REFERENCE, "reject removed TLS trust"));
+ assertEquals(SecretFailure.Code.UNAVAILABLE, trustRemoved.code());
+ assertNull(trustRemoved.getCause());
+ served.get(20, TimeUnit.SECONDS);
+ acceptor.get(20, TimeUnit.SECONDS);
+ assertEquals("trusted requests must be handled twice", 2, receivedRequests.get());
+ assertTrue(captured.toString().contains("start:Operation"));
+ assertTrue(captured.toString().contains("success:"));
+ assertTrue(captured.toString().contains("failure:"));
+ assertEquals(4, logs.size());
+ String diagnostics = captured + "\n" + logs;
+ assertFalse(diagnostics.contains("PRIVATE-VAULT-TOKEN"));
+ assertFalse(diagnostics.contains("PRIVATE-VAULT-VALUE"));
+
+ byte[] nextBody = new String(BODY, StandardCharsets.UTF_8)
+ .replace("\"version\":7", "\"version\":8")
+ .getBytes(StandardCharsets.UTF_8);
+ try (SSLServerSocket nextServer = (SSLServerSocket) nextTls.getServerSocketFactory()
+ .createServerSocket(0, 2, InetAddress.getLoopbackAddress())) {
+ nextServer.setSoTimeout(5000);
+ var nextServed = executor.submit(() -> {
+ for (int request = 0; request < 2; request++) {
+ try (SSLSocket socket = (SSLSocket) nextServer.accept()) {
+ socket.setSoTimeout(5000);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(
+ socket.getInputStream(), StandardCharsets.US_ASCII));
+ assertTrue(reader.readLine().startsWith("GET /v1/secret/data/payment/api HTTP/1.1"));
+ boolean tokenSeen = false;
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {
+ if (line.toLowerCase(java.util.Locale.ROOT).startsWith("x-vault-token:")) {
+ tokenSeen = line.trim().endsWith("PRIVATE-VAULT-TOKEN");
+ }
+ }
+ assertTrue(tokenSeen);
+ socket.getOutputStream().write(("HTTP/1.1 200 OK\r\nContent-Length: "
+ + nextBody.length + "\r\nConnection: close\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII));
+ socket.getOutputStream().write(nextBody);
+ }
+ }
+ return null;
+ });
+ var overlapNextProfile = HttpServiceProfile.builder("vault",
+ URI.create("https://127.0.0.1:" + nextServer.getLocalPort() + "/"))
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootsPem(List.of(rootPem, nextRootPem)).build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(overlapNextProfile)));
+ assertEquals("vault-kv2:8",
+ context.resolveSecret(REFERENCE, "read next CA during overlap").version());
+ var nextOnlyProfile = HttpServiceProfile.builder("vault",
+ URI.create("https://127.0.0.1:" + nextServer.getLocalPort() + "/"))
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootPem(nextRootPem).build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(nextOnlyProfile)));
+ assertEquals("vault-kv2:8",
+ context.resolveSecret(REFERENCE, "read next CA after retirement").version());
+ nextServed.get(20, TimeUnit.SECONDS);
+ }
+ assertEquals(6, logs.size());
+ String completeDiagnostics = captured + "\n" + logs;
+ assertFalse(completeDiagnostics.contains("PRIVATE-VAULT-TOKEN"));
+ assertFalse(completeDiagnostics.contains("PRIVATE-VAULT-VALUE"));
+
+ // Replacing the Context service governs new calls, but an already
+ // started call owns its old client until its bounded response ends.
+ try (SSLServerSocket heldServer = (SSLServerSocket) tls.getServerSocketFactory()
+ .createServerSocket(0, 2, InetAddress.getLoopbackAddress())) {
+ heldServer.setSoTimeout(8000);
+ CompletableFuture requestArrived = new CompletableFuture<>();
+ CompletableFuture releaseResponse = new CompletableFuture<>();
+ CompletableFuture oldRequestFinished = new CompletableFuture<>();
+ AtomicInteger heldHttpRequests = new AtomicInteger();
+ var heldServerTask = executor.submit(() -> {
+ SSLSocket oldSocket = (SSLSocket) heldServer.accept();
+ executor.execute(() -> {
+ try (oldSocket) {
+ oldSocket.setSoTimeout(8000);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(
+ oldSocket.getInputStream(), StandardCharsets.US_ASCII));
+ assertTrue(reader.readLine().startsWith(
+ "GET /v1/secret/data/payment/api HTTP/1.1"));
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {}
+ heldHttpRequests.incrementAndGet();
+ requestArrived.complete(null);
+ releaseResponse.get(8, TimeUnit.SECONDS);
+ oldSocket.getOutputStream().write(("HTTP/1.1 200 OK\r\nContent-Length: "
+ + BODY.length + "\r\nConnection: close\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII));
+ oldSocket.getOutputStream().write(BODY);
+ oldRequestFinished.complete(null);
+ } catch (Throwable failure) {
+ requestArrived.completeExceptionally(failure);
+ oldRequestFinished.completeExceptionally(failure);
+ }
+ });
+ try (SSLSocket removed = (SSLSocket) heldServer.accept()) {
+ removed.setSoTimeout(8000);
+ try {
+ String line = new BufferedReader(new InputStreamReader(
+ removed.getInputStream(), StandardCharsets.US_ASCII)).readLine();
+ if (line != null) {
+ heldHttpRequests.incrementAndGet();
+ throw new AssertionError("retired CA received a new HTTP request");
+ }
+ } catch (java.io.IOException expected) {
+ // The new client rejects the old CA before any HTTP request.
+ }
+ }
+ return null;
+ });
+ URI heldEndpoint = URI.create("https://127.0.0.1:"
+ + heldServer.getLocalPort() + "/");
+ var oldOnly = HttpServiceProfile.builder("vault", heldEndpoint)
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootPem(rootPem).build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(oldOnly)));
+ var inFlight = executor.submit(() -> context.resolveSecret(
+ REFERENCE, "read before TLS root retirement"));
+ try {
+ requestArrived.get(8, TimeUnit.SECONDS);
+ var newOnly = HttpServiceProfile.builder("vault", heldEndpoint)
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootPem(nextRootPem).build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(newOnly)));
+ SecretFailure rejected = assertThrows(SecretFailure.class,
+ () -> context.resolveSecret(REFERENCE, "reject retired CA on new call"));
+ assertEquals(SecretFailure.Code.UNAVAILABLE, rejected.code());
+ assertEquals(1, heldHttpRequests.get());
+ } finally {
+ releaseResponse.complete(null);
+ }
+ assertEquals("vault-kv2:7", inFlight.get(8, TimeUnit.SECONDS).version());
+ oldRequestFinished.get(8, TimeUnit.SECONDS);
+ heldServerTask.get(8, TimeUnit.SECONDS);
+ assertEquals(1, heldHttpRequests.get());
+ }
+ String inFlightDiagnostics = captured + "\n" + logs;
+ assertFalse(inFlightDiagnostics.contains("PRIVATE-VAULT-TOKEN"));
+ assertFalse(inFlightDiagnostics.contains("PRIVATE-VAULT-VALUE"));
+
+ // Two independent Contexts model mixed old/new certificates
+ // while client instances are upgraded at different times.
+ try (SSLServerSocket oldServer = (SSLServerSocket) tls.getServerSocketFactory()
+ .createServerSocket(0, 3, InetAddress.getLoopbackAddress());
+ SSLServerSocket newServer = (SSLServerSocket) nextTls.getServerSocketFactory()
+ .createServerSocket(0, 4, InetAddress.getLoopbackAddress())) {
+ oldServer.setSoTimeout(10000);
+ newServer.setSoTimeout(10000);
+ AtomicInteger oldRequests = new AtomicInteger();
+ AtomicInteger newRequests = new AtomicInteger();
+ var oldServed = executor.submit(() -> {
+ serveRolloverTls(oldServer, 2, true, BODY, oldRequests);
+ return null;
+ });
+ var newServed = executor.submit(() -> {
+ serveRolloverTls(newServer, 4, false, nextBody, newRequests);
+ return null;
+ });
+ List oldEvents = new CopyOnWriteArrayList<>();
+ List newEvents = new CopyOnWriteArrayList<>();
+ DefaultUserContext oldInstance = tlsContext(oldServer.getLocalPort(), rootPem,
+ oldEvents);
+ DefaultUserContext newInstance = tlsContext(newServer.getLocalPort(), nextRootPem,
+ newEvents);
+ URI oldEndpoint = URI.create("https://127.0.0.1:" + oldServer.getLocalPort() + "/");
+ URI newEndpoint = URI.create("https://127.0.0.1:" + newServer.getLocalPort() + "/");
+ var newOverlap = HttpServiceProfile.builder("vault", newEndpoint)
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootsPem(List.of(rootPem, nextRootPem)).build();
+ newInstance.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(newOverlap)));
+ var oldFirst = executor.submit(() -> oldInstance.resolveSecret(
+ REFERENCE, "old instance before overlap"));
+ var newFirst = executor.submit(() -> newInstance.resolveSecret(
+ REFERENCE, "new instance during overlap"));
+ assertEquals("vault-kv2:7", oldFirst.get(8, TimeUnit.SECONDS).version());
+ assertEquals("vault-kv2:8", newFirst.get(8, TimeUnit.SECONDS).version());
+
+ var oldOverlap = HttpServiceProfile.builder("vault", oldEndpoint)
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootsPem(List.of(rootPem, nextRootPem)).build();
+ oldInstance.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(oldOverlap)));
+ var oldSecond = executor.submit(() -> oldInstance.resolveSecret(
+ REFERENCE, "old instance during overlap"));
+ var newSecond = executor.submit(() -> newInstance.resolveSecret(
+ REFERENCE, "new instance remains on overlap"));
+ assertEquals("vault-kv2:7", oldSecond.get(8, TimeUnit.SECONDS).version());
+ assertEquals("vault-kv2:8", newSecond.get(8, TimeUnit.SECONDS).version());
+
+ var oldNewOnly = HttpServiceProfile.builder("vault", oldEndpoint)
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootPem(nextRootPem).build();
+ var newNewOnly = HttpServiceProfile.builder("vault", newEndpoint)
+ .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8))
+ .trustedRootPem(nextRootPem).build();
+ oldInstance.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(oldNewOnly)));
+ newInstance.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(newNewOnly)));
+ var newThird = executor.submit(() -> newInstance.resolveSecret(
+ REFERENCE, "new route after CA retirement"));
+ SecretFailure oldRejected = assertThrows(SecretFailure.class,
+ () -> oldInstance.resolveSecret(REFERENCE, "old route after CA retirement"));
+ assertEquals(SecretFailure.Code.UNAVAILABLE, oldRejected.code());
+ assertEquals("vault-kv2:8", newThird.get(8, TimeUnit.SECONDS).version());
+ assertEquals(2, oldRequests.get());
+
+ oldInstance.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(newNewOnly)));
+ assertEquals("vault-kv2:8", oldInstance.resolveSecret(
+ REFERENCE, "old instance after route migration").version());
+ oldServed.get(10, TimeUnit.SECONDS);
+ newServed.get(10, TimeUnit.SECONDS);
+ assertEquals(2, oldRequests.get());
+ assertEquals(4, newRequests.get());
+ for (List events : List.of(oldEvents, newEvents)) {
+ assertFalse(events.toString().contains("PRIVATE-VAULT-TOKEN"));
+ assertFalse(events.toString().contains("PRIVATE-VAULT-VALUE"));
+ }
+ }
+
+ // A rolling deployment replaces whole client processes. Keep
+ // both certificate endpoints live while independent JVMs use
+ // old-only and overlap trust, then retire the old route.
+ try (SSLServerSocket oldProcessServer = (SSLServerSocket) tls.getServerSocketFactory()
+ .createServerSocket(0, 3, InetAddress.getLoopbackAddress());
+ SSLServerSocket newProcessServer = (SSLServerSocket) nextTls.getServerSocketFactory()
+ .createServerSocket(0, 5, InetAddress.getLoopbackAddress())) {
+ oldProcessServer.setSoTimeout(30000);
+ newProcessServer.setSoTimeout(30000);
+ AtomicInteger oldProcessRequests = new AtomicInteger();
+ AtomicInteger newProcessRequests = new AtomicInteger();
+ var oldProcessTask = executor.submit(() -> {
+ serveRolloverTls(oldProcessServer, 2, true, BODY, oldProcessRequests);
+ return null;
+ });
+ var newProcessTask = executor.submit(() -> {
+ serveRolloverTls(newProcessServer, 5, false, nextBody, newProcessRequests);
+ return null;
+ });
+ Process oldClient = startRolloverChild("old-only", oldProcessServer.getLocalPort(),
+ rootPath, nextRootPath, "vault-kv2:7");
+ Process newClient = startRolloverChild("overlap", newProcessServer.getLocalPort(),
+ rootPath, nextRootPath, "vault-kv2:8");
+ assertRolloverChild(oldClient, "old-only");
+ assertRolloverChild(newClient, "overlap");
+ assertEquals(1, oldProcessRequests.get());
+ assertEquals(1, newProcessRequests.get());
+
+ oldClient = startRolloverChild("overlap", oldProcessServer.getLocalPort(),
+ rootPath, nextRootPath, "vault-kv2:7");
+ newClient = startRolloverChild("overlap", newProcessServer.getLocalPort(),
+ rootPath, nextRootPath, "vault-kv2:8");
+ assertRolloverChild(oldClient, "overlap");
+ assertRolloverChild(newClient, "overlap");
+ assertEquals(2, oldProcessRequests.get());
+ assertEquals(2, newProcessRequests.get());
+
+ // Drain the old route while overlap trust is still live.
+ Process overlapRerouted = startRolloverChild("overlap",
+ newProcessServer.getLocalPort(), rootPath, nextRootPath,
+ "vault-kv2:8");
+ assertRolloverChild(overlapRerouted, "overlap");
+ assertEquals(3, newProcessRequests.get());
+
+ oldClient = startRolloverChild("new-only", oldProcessServer.getLocalPort(),
+ rootPath, nextRootPath, "unavailable");
+ newClient = startRolloverChild("new-only", newProcessServer.getLocalPort(),
+ rootPath, nextRootPath, "vault-kv2:8");
+ assertRolloverChild(oldClient, "new-only");
+ assertRolloverChild(newClient, "new-only");
+ assertEquals("retired route received HTTP", 2, oldProcessRequests.get());
+ assertEquals(4, newProcessRequests.get());
+
+ Process reroutedClient = startRolloverChild("new-only",
+ newProcessServer.getLocalPort(), rootPath, nextRootPath,
+ "vault-kv2:8");
+ assertRolloverChild(reroutedClient, "new-only");
+ oldProcessTask.get(30, TimeUnit.SECONDS);
+ newProcessTask.get(30, TimeUnit.SECONDS);
+ assertEquals(2, oldProcessRequests.get());
+ assertEquals(5, newProcessRequests.get());
+ System.out.println("PROCESS_ROLLOVER_PASS language=java old_requests=2 new_requests=5");
+ }
+
+ // A response held beyond the whole-call budget must release
+ // the old Context call before a new trust profile is used.
+ try (SSLServerSocket stalledServer = (SSLServerSocket) tls.getServerSocketFactory()
+ .createServerSocket(0, 2, InetAddress.getLoopbackAddress())) {
+ stalledServer.setSoTimeout(8000);
+ CompletableFuture requestArrived = new CompletableFuture<>();
+ CompletableFuture releaseResponse = new CompletableFuture<>();
+ AtomicInteger stalledRequests = new AtomicInteger();
+ var stalledServerTask = executor.submit(() -> {
+ try (SSLSocket socket = (SSLSocket) stalledServer.accept()) {
+ socket.setSoTimeout(8000);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(
+ socket.getInputStream(), StandardCharsets.US_ASCII));
+ assertTrue(reader.readLine().startsWith(
+ "GET /v1/secret/data/payment/api HTTP/1.1"));
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {}
+ stalledRequests.incrementAndGet();
+ requestArrived.complete(null);
+ releaseResponse.get(8, TimeUnit.SECONDS);
+ }
+ stalledServer.setSoTimeout(300);
+ try (SSLSocket unexpected = (SSLSocket) stalledServer.accept()) {
+ throw new AssertionError("stalled Vault read started another connection");
+ } catch (java.net.SocketTimeoutException expected) {
+ return null;
+ }
+ });
+ URI stalledEndpoint = URI.create("https://127.0.0.1:"
+ + stalledServer.getLocalPort() + "/");
+ var shortOld = HttpServiceProfile.builder("vault", stalledEndpoint)
+ .budgets(Duration.ofSeconds(2), Duration.ofSeconds(2),
+ Duration.ofMillis(900))
+ .trustedRootPem(rootPem).build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(shortOld)));
+ long startedAt = System.nanoTime();
+ var stalled = executor.submit(() -> context.resolveSecret(
+ REFERENCE, "bound stalled old-CA read"));
+ try {
+ requestArrived.get(5, TimeUnit.SECONDS);
+ var shortNew = HttpServiceProfile.builder("vault", stalledEndpoint)
+ .budgets(Duration.ofSeconds(2), Duration.ofSeconds(2),
+ Duration.ofMillis(900))
+ .trustedRootPem(nextRootPem).build();
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(shortNew)));
+ var failure = assertThrows(java.util.concurrent.ExecutionException.class,
+ () -> stalled.get(4, TimeUnit.SECONDS));
+ assertTrue(failure.getCause() instanceof SecretFailure);
+ assertEquals(SecretFailure.Code.UNAVAILABLE,
+ ((SecretFailure) failure.getCause()).code());
+ assertTrue(Duration.ofNanos(System.nanoTime() - startedAt)
+ .compareTo(Duration.ofSeconds(4)) < 0);
+ assertEquals(1, stalledRequests.get());
+ } finally {
+ releaseResponse.complete(null);
+ }
+ stalledServerTask.get(5, TimeUnit.SECONDS);
+ }
+ String budgetDiagnostics = captured + "\n" + logs;
+ assertFalse(budgetDiagnostics.contains("PRIVATE-VAULT-TOKEN"));
+ assertFalse(budgetDiagnostics.contains("PRIVATE-VAULT-VALUE"));
+ } finally {
+ executor.shutdownNow();
+ }
+ } finally {
+ logger.removeHandler(handler);
+ Files.deleteIfExists(keyPath);
+ Files.deleteIfExists(certificatePath);
+ Files.deleteIfExists(nextCertificatePath);
+ Files.deleteIfExists(requestPath);
+ Files.deleteIfExists(serialPath);
+ Files.deleteIfExists(nextSerialPath);
+ Files.deleteIfExists(rootKeyPath);
+ Files.deleteIfExists(rootPath);
+ Files.deleteIfExists(nextRootKeyPath);
+ Files.deleteIfExists(nextRootPath);
+ Files.deleteIfExists(directory);
+ }
+ }
+
+ private static void serveRolloverTls(SSLServerSocket server, int successfulCalls,
+ boolean rejectLast, byte[] body,
+ AtomicInteger receivedRequests) throws Exception {
+ int attempts = successfulCalls + (rejectLast ? 1 : 0);
+ for (int attempt = 0; attempt < attempts; attempt++) {
+ try (SSLSocket socket = (SSLSocket) server.accept()) {
+ socket.setSoTimeout(8000);
+ try {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(
+ socket.getInputStream(), StandardCharsets.US_ASCII));
+ String firstLine = reader.readLine();
+ if (rejectLast && attempt == successfulCalls) {
+ assertNull("retired CA received an HTTP request", firstLine);
+ continue;
+ }
+ assertNotNull("trusted rollover request missing", firstLine);
+ assertTrue(firstLine.startsWith("GET /v1/secret/data/payment/api HTTP/1.1"));
+ boolean tokenSeen = false;
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {
+ if (line.toLowerCase(java.util.Locale.ROOT).startsWith("x-vault-token:")) {
+ tokenSeen = line.trim().endsWith("PRIVATE-VAULT-TOKEN");
+ }
+ }
+ assertTrue(tokenSeen);
+ receivedRequests.incrementAndGet();
+ socket.getOutputStream().write(("HTTP/1.1 200 OK\r\nContent-Length: "
+ + body.length + "\r\nConnection: close\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII));
+ socket.getOutputStream().write(body);
+ } catch (java.io.IOException incompleteHandshake) {
+ if (!rejectLast || attempt != successfulCalls) {
+ throw incompleteHandshake;
+ }
+ }
+ }
+ }
+ }
+
+ private static Process startRolloverChild(String mode, int port,
+ java.nio.file.Path rootPath, java.nio.file.Path nextRootPath,
+ String expected) throws Exception {
+ String javaBinary = java.nio.file.Path.of(System.getProperty("java.home"), "bin", "java")
+ .toString();
+ String classpath = System.getProperty("surefire.test.class.path",
+ System.getProperty("java.class.path"));
+ return new ProcessBuilder(javaBinary, "-cp", classpath, VaultKv2ExampleTest.class.getName(),
+ "--rollover-child", mode, Integer.toString(port), rootPath.toString(),
+ nextRootPath.toString(), expected).redirectErrorStream(true).start();
+ }
+
+ private static void assertRolloverChild(Process child, String mode) throws Exception {
+ if (!child.waitFor(20, TimeUnit.SECONDS)) {
+ child.destroyForcibly();
+ fail("TLS rollover child timed out: " + mode);
+ }
+ String output = new String(child.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ assertEquals("TLS rollover child failed: " + mode + "\n" + output,
+ 0, child.exitValue());
+ assertTrue("TLS rollover child lacked process evidence: " + output,
+ output.contains("ROLLOVER_CHILD_PASS pid=" + child.pid() + " mode=" + mode));
+ assertFalse(output.contains("PRIVATE-VAULT-TOKEN"));
+ assertFalse(output.contains("PRIVATE-VAULT-VALUE"));
+ }
+
+ public static void main(String[] args) throws Exception {
+ if (args.length != 6 || !"--rollover-child".equals(args[0])) {
+ throw new IllegalArgumentException("expected --rollover-child MODE PORT OLD_CA NEW_CA EXPECTED");
+ }
+ String mode = args[1];
+ int port = Integer.parseInt(args[2]);
+ byte[] root = Files.readAllBytes(java.nio.file.Path.of(args[3]));
+ byte[] nextRoot = Files.readAllBytes(java.nio.file.Path.of(args[4]));
+ List captured = new CopyOnWriteArrayList<>();
+ DefaultUserContext context = new VaultKv2ExampleTest().tlsContext(port, root, captured);
+ URI endpoint = URI.create("https://127.0.0.1:" + port + "/");
+ var builder = HttpServiceProfile.builder("vault", endpoint)
+ .budgets(Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ofSeconds(3));
+ switch (mode) {
+ case "old-only" -> builder.trustedRootPem(root);
+ case "overlap" -> builder.trustedRootsPem(List.of(root, nextRoot));
+ case "new-only" -> builder.trustedRootPem(nextRoot);
+ default -> throw new IllegalArgumentException("unknown trust mode");
+ }
+ context.putAttribute(HttpRequestService.class.getName(),
+ new JdkHttpRequestService(List.of(builder.build())));
+ if ("unavailable".equals(args[5])) {
+ SecretFailure failure = assertThrows(SecretFailure.class,
+ () -> context.resolveSecret(REFERENCE, "rollover process trust probe"));
+ assertEquals(SecretFailure.Code.UNAVAILABLE, failure.code());
+ } else {
+ assertEquals(args[5], context.resolveSecret(REFERENCE,
+ "rollover process trust probe").version());
+ }
+ assertFalse(captured.toString().contains("PRIVATE-VAULT-TOKEN"));
+ assertFalse(captured.toString().contains("PRIVATE-VAULT-VALUE"));
+ System.out.println("ROLLOVER_CHILD_PASS pid=" + ProcessHandle.current().pid()
+ + " mode=" + mode + " outcome=" + args[5]);
+ }
+
+ @Test public void loopbackVaultPathTokenVersionAndTimeout() throws Exception {
+ var successExecutor = Executors.newSingleThreadExecutor();
+ try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+ server.setSoTimeout(3000);
+ var served = successExecutor.submit(() -> {
+ try (Socket socket = server.accept()) {
+ socket.setSoTimeout(2000);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(
+ socket.getInputStream(), StandardCharsets.US_ASCII));
+ assertTrue(reader.readLine().startsWith("GET /v1/secret/data/payment/api HTTP/1.1"));
+ boolean tokenSeen = false;
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {
+ if (line.toLowerCase(java.util.Locale.ROOT).startsWith("x-vault-token:")) {
+ tokenSeen = line.trim().endsWith("PRIVATE-VAULT-TOKEN");
+ }
+ }
+ assertTrue(tokenSeen);
+ socket.getOutputStream().write(("HTTP/1.1 200 OK\r\nContent-Length: "
+ + BODY.length + "\r\nConnection: close\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII));
+ socket.getOutputStream().write(BODY);
+ }
+ return null;
+ });
+ SecretValue value = context(server.getLocalPort(), Duration.ofSeconds(2))
+ .resolveSecret(REFERENCE, "read configured credential");
+ assertEquals("PRIVATE-VAULT-VALUE", new String(value.exposeBytes(), StandardCharsets.UTF_8));
+ assertEquals("vault-kv2:7", value.version());
+ assertFalse(value.toString().contains("PRIVATE-VAULT-VALUE"));
+ served.get(3, TimeUnit.SECONDS);
+ } finally {
+ successExecutor.shutdownNow();
+ }
+
+ var slowExecutor = Executors.newSingleThreadExecutor();
+ try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+ server.setSoTimeout(3000);
+ var served = slowExecutor.submit(() -> {
+ try (Socket ignored = server.accept()) {
+ Thread.sleep(250);
+ }
+ return null;
+ });
+ SecretFailure failure = assertThrows(SecretFailure.class,
+ () -> context(server.getLocalPort(), Duration.ofMillis(50))
+ .resolveSecret(REFERENCE, "read slow credential"));
+ assertEquals(SecretFailure.Code.UNAVAILABLE, failure.code());
+ assertNull(failure.getCause());
+ assertFalse(failure.toString().contains("PRIVATE-VAULT"));
+ served.get(3, TimeUnit.SECONDS);
+ } finally {
+ slowExecutor.shutdownNow();
+ }
+ }
+}
diff --git a/examples/secret-provider/verify.sh b/examples/secret-provider/verify.sh
new file mode 100644
index 00000000..a227f483
--- /dev/null
+++ b/examples/secret-provider/verify.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+command -v openssl >/dev/null || { echo 'OpenSSL CLI is required for the local TLS fixture' >&2; exit 1; }
+repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$repo"
+evidence_dir="${TEAQL_SECRET_EVIDENCE_DIR:-}"
+if [[ -n "$evidence_dir" ]]; then
+ [[ "$evidence_dir" == /* && ! -e "$evidence_dir" && ! -L "$evidence_dir" ]] || {
+ echo 'Use a new absolute Secret Provider evidence directory' >&2; exit 1;
+ }
+ mkdir -p "$evidence_dir"
+fi
+for round in 1 2; do
+ native_status=0
+ output="$(mvn -B -q -pl examples/secret-provider -am \
+ -Dtest=SecretProviderExampleTest,VaultKv2ExampleTest -Dsurefire.failIfNoSpecifiedTests=false test 2>&1)" || native_status=$?
+ if [[ -n "$evidence_dir" ]]; then
+ printf '%s\n' "$output" >"$evidence_dir/native-$round.log"
+ fi
+ if (( native_status != 0 )); then
+ printf 'Java Secret Provider example round %s failed\n%s\n' "$round" "$output" >&2
+ exit "$native_status"
+ fi
+ if [[ "$output" == *"private-example-key"* || "$output" == *"PRIVATE-VAULT"* ]]; then
+ printf 'Java Secret Provider example round %s leaked a canary\n' "$round" >&2
+ exit 1
+ fi
+ if [[ "$output" != *"SECRET_ROTATION_LEDGER_UNCHANGED_PASS language=java context=1 clock=1 pending=1"* ||
+ "$output" != *"SECRET_CONTEXT_LEDGER_ISOLATION_PASS language=java overlap=1 same_id=1 clear_isolated=1"* ]]; then
+ printf 'Java Secret Provider example round %s lacked rotation/isolation evidence\n' "$round" >&2
+ exit 1
+ fi
+ printf 'PASS Java Secret Provider example round %s\n' "$round"
+done
diff --git a/examples/verify-runtime-examples.sh b/examples/verify-runtime-examples.sh
index 2e072eab..077103cd 100755
--- a/examples/verify-runtime-examples.sh
+++ b/examples/verify-runtime-examples.sh
@@ -85,9 +85,10 @@ for repetition in 1 2; do
done
bash "$repo_dir/examples/trace-chain/verify.sh"
+bash "$repo_dir/examples/secret-provider/verify.sh"
if [[ -n "${TEAQL_CODEGEN_DIR:-}" ]]; then
bash "$repo_dir/examples/shared-load-state/verify.sh" --generate
else
bash "$repo_dir/examples/shared-load-state/verify.sh"
fi
-printf 'PASS Java runtime examples: 4/4\n'
+printf 'PASS Java runtime examples: 5/5\n'
diff --git a/pom.xml b/pom.xml
index 9a3262bd..4a279de0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -79,6 +79,7 @@
teaql-object-storage-s3
examples/object-storage
examples/http-resilience
+ examples/secret-provider
examples/business-id-runtime
examples/security-foundations
examples/round-trip-reference-runtime
diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh
index 204f9989..e452e382 100755
--- a/scripts/verify-examples.sh
+++ b/scripts/verify-examples.sh
@@ -4,7 +4,7 @@ set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
verification_dir="$(mktemp -d)"
trap 'rm -rf -- "$verification_dir"' EXIT
-expected=(business-id-runtime conformance http-resilience object-storage order-management round-trip-reference-runtime school-management security-foundations shared-load-state standard-order trace-chain)
+expected=(business-id-runtime conformance http-resilience object-storage order-management round-trip-reference-runtime school-management secret-provider security-foundations shared-load-state standard-order trace-chain)
mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
if [[ "${actual[*]}" != "${expected[*]}" ]]; then
echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2
@@ -14,6 +14,7 @@ fi
cd "$repo"
bash examples/object-storage/verify.sh
bash examples/http-resilience/verify.sh
+bash examples/secret-provider/verify.sh
mvn -q -DskipTests install
mvn -q -pl teaql-sqlite \
-Dtest=DerivedQueryTraceSqliteTest,LikeIntentPrivacySqliteTest,TypedIntentPrivacySqliteTest test
diff --git a/teaql-core/pom.xml b/teaql-core/pom.xml
index 161317bc..d934298d 100644
--- a/teaql-core/pom.xml
+++ b/teaql-core/pom.xml
@@ -34,5 +34,11 @@
4.13.2
test
+
+ com.google.code.gson
+ gson
+ 2.14.0
+ test
+
diff --git a/teaql-core/src/main/java/io/teaql/core/UserContext.java b/teaql-core/src/main/java/io/teaql/core/UserContext.java
index b51bb1c2..eed37077 100644
--- a/teaql-core/src/main/java/io/teaql/core/UserContext.java
+++ b/teaql-core/src/main/java/io/teaql/core/UserContext.java
@@ -246,6 +246,34 @@ default io.teaql.core.http.HttpRequestService httpRequests() {
return service;
}
+ /** Resolves only a startup-bound alias with an explicit execution comment. */
+ default io.teaql.core.secret.SecretValue resolveSecret(
+ io.teaql.core.secret.SecretReference reference, String comment) {
+ if (reference == null || comment == null || comment.isBlank()) {
+ throw new io.teaql.core.secret.SecretFailure(
+ io.teaql.core.secret.SecretFailure.Code.INVALID_INPUT);
+ }
+ io.teaql.core.secret.SecretProvider provider =
+ capability(io.teaql.core.secret.SecretProvider.class);
+ if (provider == null) {
+ throw new io.teaql.core.secret.SecretFailure(
+ io.teaql.core.secret.SecretFailure.Code.PROVIDER_NOT_CONFIGURED);
+ }
+ try {
+ io.teaql.core.secret.SecretValue value = provider.resolve(this, reference);
+ if (value == null) {
+ throw new io.teaql.core.secret.SecretFailure(
+ io.teaql.core.secret.SecretFailure.Code.UNAVAILABLE);
+ }
+ return value;
+ } catch (io.teaql.core.secret.SecretFailure safe) {
+ throw safe;
+ } catch (RuntimeException unsafe) {
+ throw new io.teaql.core.secret.SecretFailure(
+ io.teaql.core.secret.SecretFailure.Code.UNAVAILABLE);
+ }
+ }
+
default T capability(Class capabilityType) {
return null;
}
diff --git a/teaql-core/src/main/java/io/teaql/core/http/HttpRequestService.java b/teaql-core/src/main/java/io/teaql/core/http/HttpRequestService.java
index 49de2b04..e05fd2c8 100644
--- a/teaql-core/src/main/java/io/teaql/core/http/HttpRequestService.java
+++ b/teaql-core/src/main/java/io/teaql/core/http/HttpRequestService.java
@@ -15,17 +15,37 @@
/** Context-owned, provider-neutral HTTP boundary for configured external services. */
public interface HttpRequestService {
+ /** Sensitive adapters fail closed unless the installed transport attests
+ * to the trusted profile it will actually use. */
+ default boolean profileIsEncryptedOrLoopback(String profile) { return false; }
+
enum Method { GET, POST, PUT, DELETE }
enum IntentKind { PURPOSE, AUDIT }
enum RetryMode { NONE, CONFIRMED_IDEMPOTENT }
- record Call(String profile, Method method, String relativePath, Map headers,
- byte[] body, String contentType, String comment, IntentKind intentKind,
- String intent, RetryMode retryMode, List parentTraceChain) {
+ @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = Call.BlockedSerializer.class)
+ final class Call {
private static final com.fasterxml.jackson.databind.ObjectMapper TRACE_JSON =
new com.fasterxml.jackson.databind.ObjectMapper();
- public Call {
+ // HTTP requests can contain credentials in headers, paths and bodies.
+ // Transient fields also prevent Gson's default reflective serializer
+ // from copying them into diagnostic JSON.
+ private final transient String profile;
+ private final transient Method method;
+ private final transient String relativePath;
+ private final transient Map headers;
+ private final transient byte[] body;
+ private final transient String contentType;
+ private final transient String comment;
+ private final transient IntentKind intentKind;
+ private final transient String intent;
+ private final transient RetryMode retryMode;
+ private final transient List parentTraceChain;
+
+ public Call(String profile, Method method, String relativePath, Map headers,
+ byte[] body, String contentType, String comment, IntentKind intentKind,
+ String intent, RetryMode retryMode, List parentTraceChain) {
if (profile == null || !profile.matches("[A-Za-z][A-Za-z0-9_-]{0,63}")
|| method == null || relativePath == null || relativePath.isBlank()
|| comment == null || comment.isBlank() || intentKind == null
@@ -36,18 +56,28 @@ record Call(String profile, Method method, String relativePath, Map entry.getKey() == null || entry.getValue() == null)) {
throw new HttpRequestFailure(HttpRequestFailure.Code.INVALID_INPUT);
}
- headers = headers == null ? Map.of() : Map.copyOf(headers);
- body = body == null ? new byte[0] : body.clone();
+ Map safeHeaders = headers == null ? Map.of() : Map.copyOf(headers);
+ byte[] safeBody = body == null ? new byte[0] : body.clone();
if (contentType != null && contentType.chars().anyMatch(c -> c < 32 || c == 127)) {
throw new HttpRequestFailure(HttpRequestFailure.Code.INVALID_INPUT);
}
- if ((method == Method.GET || method == Method.DELETE) && body.length != 0) {
+ if ((method == Method.GET || method == Method.DELETE) && safeBody.length != 0) {
throw new HttpRequestFailure(HttpRequestFailure.Code.INVALID_INPUT);
}
if (parentTraceChain == null || !validParentTraceChain(parentTraceChain)) {
throw new HttpRequestFailure(HttpRequestFailure.Code.INVALID_INPUT);
}
- parentTraceChain = List.copyOf(parentTraceChain);
+ this.profile = profile;
+ this.method = method;
+ this.relativePath = relativePath;
+ this.headers = safeHeaders;
+ this.body = safeBody;
+ this.contentType = contentType;
+ this.comment = comment;
+ this.intentKind = intentKind;
+ this.intent = intent;
+ this.retryMode = retryMode;
+ this.parentTraceChain = List.copyOf(parentTraceChain);
}
/** Preserve the original ten-argument API; lineage is opt-in and request-owned. */
@@ -58,9 +88,50 @@ public Call(String profile, Method method, String relativePath, Map headers() { return headers; }
+ public byte[] body() { return body.clone(); }
+ public String contentType() { return contentType; }
+ public String comment() { return comment; }
+ public IntentKind intentKind() { return intentKind; }
+ public String intent() { return intent; }
+ public RetryMode retryMode() { return retryMode; }
+ public List parentTraceChain() { return parentTraceChain; }
@Override public String toString() { return "HttpCall[profile=" + profile + ", method=" + method + ", details=masked]"; }
+ @Override public boolean equals(Object other) {
+ if (!(other instanceof Call call)) return false;
+ return java.util.Objects.equals(profile, call.profile)
+ && method == call.method
+ && java.util.Objects.equals(relativePath, call.relativePath)
+ && java.util.Objects.equals(headers, call.headers)
+ // A record's byte[] component used reference equality.
+ && body == call.body
+ && java.util.Objects.equals(contentType, call.contentType)
+ && java.util.Objects.equals(comment, call.comment)
+ && intentKind == call.intentKind
+ && java.util.Objects.equals(intent, call.intent)
+ && retryMode == call.retryMode
+ && java.util.Objects.equals(parentTraceChain, call.parentTraceChain);
+ }
+
+ @Override public int hashCode() {
+ return java.util.Objects.hash(profile, method, relativePath, headers, body,
+ contentType, comment, intentKind, intent, retryMode, parentTraceChain);
+ }
+
+ public static final class BlockedSerializer
+ extends com.fasterxml.jackson.databind.JsonSerializer {
+ @Override public void serialize(Call value,
+ com.fasterxml.jackson.core.JsonGenerator generator,
+ com.fasterxml.jackson.databind.SerializerProvider serializers)
+ throws java.io.IOException {
+ throw new java.io.IOException("HTTP request serialization denied");
+ }
+ }
+
/** Attach an immutable snapshot from the initiating request, never the Context stack. */
public Call withParentTraceChain(List parent) {
return new Call(profile, method, relativePath, headers, body, contentType,
@@ -128,15 +199,25 @@ public static Call postText(String profile, String path, String text, String com
}
}
- record Response(int status, byte[] body, int attempts) {
- public Response {
+ @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = Response.BlockedSerializer.class)
+ final class Response {
+ // A response can contain a Vault secret or another credential.
+ private final transient int status;
+ private final transient byte[] body;
+ private final transient int attempts;
+
+ public Response(int status, byte[] body, int attempts) {
if (status < 100 || status > 599 || body == null || attempts < 1) {
throw new IllegalArgumentException("invalid HTTP response");
}
- body = body.clone();
+ this.status = status;
+ this.body = body.clone();
+ this.attempts = attempts;
}
- @Override public byte[] body() { return body.clone(); }
+ public int status() { return status; }
+ public byte[] body() { return body.clone(); }
+ public int attempts() { return attempts; }
public String text() {
try {
return StandardCharsets.UTF_8.newDecoder()
@@ -155,6 +236,25 @@ public Object jsonValue() {
}
}
@Override public String toString() { return "HttpResponse[status=" + status + ", attempts=" + attempts + ", body=masked]"; }
+
+ @Override public boolean equals(Object other) {
+ return other instanceof Response response && status == response.status
+ && body == response.body && attempts == response.attempts;
+ }
+
+ @Override public int hashCode() {
+ return java.util.Objects.hash(status, body, attempts);
+ }
+
+ public static final class BlockedSerializer
+ extends com.fasterxml.jackson.databind.JsonSerializer {
+ @Override public void serialize(Response value,
+ com.fasterxml.jackson.core.JsonGenerator generator,
+ com.fasterxml.jackson.databind.SerializerProvider serializers)
+ throws java.io.IOException {
+ throw new java.io.IOException("HTTP response serialization denied");
+ }
+ }
}
Response execute(UserContext context, Call call);
diff --git a/teaql-core/src/main/java/io/teaql/core/secret/SecretFailure.java b/teaql-core/src/main/java/io/teaql/core/secret/SecretFailure.java
new file mode 100644
index 00000000..cdb1130a
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/secret/SecretFailure.java
@@ -0,0 +1,20 @@
+package io.teaql.core.secret;
+
+/** Deliberately has no provider cause, file path, variable name or secret text. */
+public final class SecretFailure extends RuntimeException {
+ public enum Code {
+ INVALID_INPUT, PROVIDER_NOT_CONFIGURED, UNKNOWN_BINDING, MISSING,
+ DENIED, EXPIRED, UNAVAILABLE
+ }
+
+ private final Code code;
+
+ public SecretFailure(Code code) {
+ super("secret " + code.name().toLowerCase(java.util.Locale.ROOT));
+ this.code = code;
+ }
+
+ public Code code() {
+ return code;
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/secret/SecretProvider.java b/teaql-core/src/main/java/io/teaql/core/secret/SecretProvider.java
new file mode 100644
index 00000000..6f22bd5a
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/secret/SecretProvider.java
@@ -0,0 +1,8 @@
+package io.teaql.core.secret;
+
+import io.teaql.core.UserContext;
+
+/** Customer-installable provider SPI; Context is always part of resolution. */
+public interface SecretProvider {
+ SecretValue resolve(UserContext context, SecretReference reference);
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/secret/SecretReference.java b/teaql-core/src/main/java/io/teaql/core/secret/SecretReference.java
new file mode 100644
index 00000000..c9a3fad7
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/secret/SecretReference.java
@@ -0,0 +1,14 @@
+package io.teaql.core.secret;
+
+/** Names a trusted startup binding, never a process variable or filesystem path. */
+public record SecretReference(String profile, String alias) {
+ public SecretReference {
+ if (!valid(profile) || !valid(alias)) {
+ throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ }
+ }
+
+ private static boolean valid(String value) {
+ return value != null && value.matches("[A-Za-z][A-Za-z0-9_-]{0,63}");
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/secret/SecretValue.java b/teaql-core/src/main/java/io/teaql/core/secret/SecretValue.java
new file mode 100644
index 00000000..3b7e309e
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/secret/SecretValue.java
@@ -0,0 +1,58 @@
+package io.teaql.core.secret;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import java.io.IOException;
+import java.time.Instant;
+
+/** Small secret wrapper; plaintext access must be an explicit adapter action. */
+@JsonSerialize(using = SecretValue.BlockedSerializer.class)
+public final class SecretValue {
+ public static final int MAX_BYTES = 16 * 1024;
+ // Reflective serializers such as Gson skip transient fields. Jackson is
+ // denied by the class-level serializer; neither path may emit this value.
+ private final transient byte[] bytes;
+ private final transient String version;
+ private final transient Instant expiresAt;
+
+ public SecretValue(byte[] bytes, String version, Instant expiresAt) {
+ if (bytes == null || bytes.length == 0 || bytes.length > MAX_BYTES) {
+ throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ }
+ if (expiresAt != null && !expiresAt.isAfter(Instant.now())) {
+ throw new SecretFailure(SecretFailure.Code.EXPIRED);
+ }
+ this.bytes = bytes.clone();
+ this.version = version;
+ this.expiresAt = expiresAt;
+ }
+
+ /** Deliberate adapter-only plaintext access. Never log or serialize the result. */
+ public byte[] exposeBytes() {
+ if (expiresAt != null && !expiresAt.isAfter(Instant.now())) {
+ throw new SecretFailure(SecretFailure.Code.EXPIRED);
+ }
+ return bytes.clone();
+ }
+
+ public String version() {
+ return version;
+ }
+
+ public Instant expiresAt() {
+ return expiresAt;
+ }
+
+ @Override public String toString() {
+ return "SecretValue[masked]";
+ }
+
+ public static final class BlockedSerializer extends JsonSerializer {
+ @Override public void serialize(SecretValue value, JsonGenerator generator,
+ SerializerProvider serializers) throws IOException {
+ throw new IOException("secret serialization denied");
+ }
+ }
+}
diff --git a/teaql-core/src/main/java/module-info.java b/teaql-core/src/main/java/module-info.java
index 07edfde3..c6f37afa 100644
--- a/teaql-core/src/main/java/module-info.java
+++ b/teaql-core/src/main/java/module-info.java
@@ -16,6 +16,7 @@
exports io.teaql.core.spi;
exports io.teaql.core.storage;
exports io.teaql.core.http;
+ exports io.teaql.core.secret;
exports io.teaql.core.businessid;
exports io.teaql.core.reference;
diff --git a/teaql-core/src/test/java/io/teaql/core/http/HttpCallSerializationTest.java b/teaql-core/src/test/java/io/teaql/core/http/HttpCallSerializationTest.java
new file mode 100644
index 00000000..7ba8e55f
--- /dev/null
+++ b/teaql-core/src/test/java/io/teaql/core/http/HttpCallSerializationTest.java
@@ -0,0 +1,32 @@
+package io.teaql.core.http;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gson.Gson;
+import java.util.Map;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class HttpCallSerializationTest {
+ @Test public void structuredSerializationDoesNotRevealCredentialHeaders() throws Exception {
+ String canary = "PRIVATE-VAULT-TOKEN-CANARY";
+ var call = new HttpRequestService.Call(
+ "vault", HttpRequestService.Method.GET, "v1/secret/data/signing",
+ Map.of("X-Vault-Token", canary), null, null,
+ "read configured secret", HttpRequestService.IntentKind.PURPOSE,
+ "resolve runtime credential", HttpRequestService.RetryMode.NONE);
+
+ Exception jacksonFailure = assertThrows(Exception.class,
+ () -> new ObjectMapper().writeValueAsString(call));
+ assertFalse(jacksonFailure.toString().contains(canary));
+ assertEquals("{}", new Gson().toJson(call));
+ assertFalse(call.toString().contains(canary));
+
+ var response = new HttpRequestService.Response(200,
+ ("{\"secret\":\"" + canary + "\"}").getBytes(java.nio.charset.StandardCharsets.UTF_8), 1);
+ Exception responseFailure = assertThrows(Exception.class,
+ () -> new ObjectMapper().writeValueAsString(response));
+ assertFalse(responseFailure.toString().contains(canary));
+ assertEquals("{}", new Gson().toJson(response));
+ assertFalse(response.toString().contains(canary));
+ }
+}
diff --git a/teaql-core/src/test/java/io/teaql/core/secret/SecretValueSerializationTest.java b/teaql-core/src/test/java/io/teaql/core/secret/SecretValueSerializationTest.java
new file mode 100644
index 00000000..a8f1cfc1
--- /dev/null
+++ b/teaql-core/src/test/java/io/teaql/core/secret/SecretValueSerializationTest.java
@@ -0,0 +1,25 @@
+package io.teaql.core.secret;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gson.Gson;
+import java.nio.charset.StandardCharsets;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class SecretValueSerializationTest {
+ @Test public void commonJsonSerializersDoNotExposeSecretFields() throws Exception {
+ SecretValue value = new SecretValue(
+ "SENSITIVE_CANARY".getBytes(StandardCharsets.UTF_8), "v1", null);
+
+ String gsonJson = new Gson().toJson(value);
+ assertEquals("{}", gsonJson);
+ assertFalse(gsonJson.contains("SENSITIVE_CANARY"));
+
+ Exception jacksonFailure = assertThrows(Exception.class,
+ () -> new ObjectMapper().writeValueAsString(value));
+ assertFalse(jacksonFailure.toString().contains("SENSITIVE_CANARY"));
+ assertEquals("SENSITIVE_CANARY",
+ new String(value.exposeBytes(), StandardCharsets.UTF_8));
+ assertEquals("v1", value.version());
+ }
+}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java
index 785c2558..85422923 100644
--- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java
@@ -36,6 +36,13 @@ public TeaQLRuntime getRuntime() {
return runtime;
}
+ /** Trusted bootstrap installation; never bind a provider from request data. */
+ public DefaultUserContext withSecretProvider(io.teaql.core.secret.SecretProvider provider) {
+ if (provider == null) throw new IllegalArgumentException("provider must not be null");
+ putAttribute(io.teaql.core.secret.SecretProvider.class.getName(), provider);
+ return this;
+ }
+
@Override
public T internalExecuteForOne(SearchRequest searchRequest) {
SmartList list = internalExecuteForList(searchRequest);
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/secret/BoundSecretProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/secret/BoundSecretProvider.java
new file mode 100644
index 00000000..ec02e919
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/secret/BoundSecretProvider.java
@@ -0,0 +1,193 @@
+package io.teaql.runtime.secret;
+
+import io.teaql.core.UserContext;
+import io.teaql.core.secret.SecretFailure;
+import io.teaql.core.secret.SecretProvider;
+import io.teaql.core.secret.SecretReference;
+import io.teaql.core.secret.SecretValue;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.channels.Channels;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.SecureDirectoryStream;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFileAttributes;
+import java.nio.file.attribute.PosixFilePermission;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Set;
+import java.util.HashMap;
+import java.util.function.Function;
+
+/** No-cache baseline with startup-bound aliases and owner-only single-file bindings. */
+public final class BoundSecretProvider implements SecretProvider {
+ private record Binding(String variable, Path file, String version, Instant expiresAt) {}
+
+ private final Path root;
+ private final Object rootFileKey;
+ private final Map bindings;
+ private final Function environment;
+
+ private BoundSecretProvider(Path root, Object rootFileKey, Map bindings,
+ Function environment) {
+ this.root = root;
+ this.rootFileKey = rootFileKey;
+ this.bindings = Map.copyOf(bindings);
+ this.environment = environment;
+ }
+
+ public static Builder builder(Path fileRoot) {
+ return new Builder(fileRoot, System::getenv);
+ }
+
+ static Builder testBuilder(Path fileRoot, Function environment) {
+ return new Builder(fileRoot, environment);
+ }
+
+ @Override public SecretValue resolve(UserContext context, SecretReference reference) {
+ if (context == null || reference == null) throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ Binding binding = bindings.get(reference);
+ if (binding == null) throw new SecretFailure(SecretFailure.Code.UNKNOWN_BINDING);
+ if (binding.expiresAt() != null && !binding.expiresAt().isAfter(Instant.now())) {
+ throw new SecretFailure(SecretFailure.Code.EXPIRED);
+ }
+ byte[] bytes;
+ if (binding.variable() != null) {
+ String value = environment.apply(binding.variable());
+ if (value == null) throw new SecretFailure(SecretFailure.Code.MISSING);
+ bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ } else {
+ bytes = readFile(root, rootFileKey, binding.file());
+ }
+ return new SecretValue(bytes, binding.version(), binding.expiresAt());
+ }
+
+ private static byte[] readFile(Path root, Object rootFileKey, Path relative) {
+ if (Files.isSymbolicLink(root)) throw new SecretFailure(SecretFailure.Code.DENIED);
+ try (DirectoryStream directory = Files.newDirectoryStream(root)) {
+ if (!(directory instanceof SecureDirectoryStream secure)) {
+ throw new SecretFailure(SecretFailure.Code.DENIED);
+ }
+ PosixFileAttributeView rootView = secure.getFileAttributeView(PosixFileAttributeView.class);
+ if (rootView == null) throw new SecretFailure(SecretFailure.Code.DENIED);
+ PosixFileAttributes rootAttributes = rootView.readAttributes();
+ if (!rootAttributes.isDirectory()
+ || !java.util.Objects.equals(rootFileKey, rootAttributes.fileKey())
+ || rootAttributes.permissions().contains(PosixFilePermission.GROUP_WRITE)
+ || rootAttributes.permissions().contains(PosixFilePermission.OTHERS_WRITE)) {
+ throw new SecretFailure(SecretFailure.Code.DENIED);
+ }
+ PosixFileAttributeView fileView = secure.getFileAttributeView(relative,
+ PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
+ if (fileView == null) throw new SecretFailure(SecretFailure.Code.DENIED);
+ PosixFileAttributes before = fileView.readAttributes();
+ if (!privateRegularFile(before)) throw new SecretFailure(SecretFailure.Code.DENIED);
+ try (SeekableByteChannel channel = secure.newByteChannel(relative,
+ Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS));
+ InputStream stream = Channels.newInputStream(channel)) {
+ if (channel.size() > SecretValue.MAX_BYTES) throw new SecretFailure(SecretFailure.Code.DENIED);
+ byte[] bytes = stream.readNBytes(SecretValue.MAX_BYTES + 1);
+ PosixFileAttributes after = fileView.readAttributes();
+ if (bytes.length > SecretValue.MAX_BYTES || !privateRegularFile(after)
+ || !java.util.Objects.equals(before.fileKey(), after.fileKey())) {
+ throw new SecretFailure(SecretFailure.Code.DENIED);
+ }
+ return bytes;
+ }
+ } catch (SecretFailure failure) {
+ throw failure;
+ } catch (java.nio.file.NoSuchFileException failure) {
+ throw new SecretFailure(SecretFailure.Code.MISSING);
+ } catch (IOException failure) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ }
+
+ private static boolean privateRegularFile(PosixFileAttributes attributes) {
+ Set mode = attributes.permissions();
+ return attributes.isRegularFile() && attributes.fileKey() != null
+ && attributes.size() <= SecretValue.MAX_BYTES
+ && !mode.contains(PosixFilePermission.GROUP_READ)
+ && !mode.contains(PosixFilePermission.GROUP_WRITE)
+ && !mode.contains(PosixFilePermission.GROUP_EXECUTE)
+ && !mode.contains(PosixFilePermission.OTHERS_READ)
+ && !mode.contains(PosixFilePermission.OTHERS_WRITE)
+ && !mode.contains(PosixFilePermission.OTHERS_EXECUTE);
+ }
+
+ public static final class Builder {
+ private final Path root;
+ private final Object rootFileKey;
+ private final Function environment;
+ private final Map bindings = new HashMap<>();
+
+ private Builder(Path fileRoot, Function environment) {
+ this.environment = java.util.Objects.requireNonNull(environment);
+ if (fileRoot == null) {
+ this.root = null;
+ this.rootFileKey = null;
+ } else {
+ try {
+ if (Files.isSymbolicLink(fileRoot) || !Files.isDirectory(fileRoot,
+ LinkOption.NOFOLLOW_LINKS)) {
+ throw new SecretFailure(SecretFailure.Code.DENIED);
+ }
+ Set rootMode = Files.getPosixFilePermissions(fileRoot,
+ LinkOption.NOFOLLOW_LINKS);
+ if (rootMode.contains(PosixFilePermission.GROUP_WRITE)
+ || rootMode.contains(PosixFilePermission.OTHERS_WRITE)) {
+ throw new SecretFailure(SecretFailure.Code.DENIED);
+ }
+ this.root = fileRoot.toRealPath(LinkOption.NOFOLLOW_LINKS);
+ PosixFileAttributes resolved = Files.readAttributes(this.root,
+ PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
+ if (!resolved.isDirectory() || resolved.fileKey() == null
+ || resolved.permissions().contains(PosixFilePermission.GROUP_WRITE)
+ || resolved.permissions().contains(PosixFilePermission.OTHERS_WRITE)) {
+ throw new SecretFailure(SecretFailure.Code.DENIED);
+ }
+ this.rootFileKey = resolved.fileKey();
+ } catch (IOException failure) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ } catch (UnsupportedOperationException failure) {
+ throw new SecretFailure(SecretFailure.Code.DENIED);
+ }
+ }
+ }
+
+ public Builder environment(SecretReference reference, String variable,
+ String version, Instant expiresAt) {
+ if (reference == null || variable == null || !variable.matches("[A-Za-z0-9_]+")) {
+ throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ }
+ bindings.put(reference, new Binding(variable, null, version, expiresAt));
+ return this;
+ }
+
+ public Builder file(SecretReference reference, Path relative,
+ String version, Instant expiresAt) {
+ if (root == null) throw new SecretFailure(SecretFailure.Code.PROVIDER_NOT_CONFIGURED);
+ if (reference == null || relative == null || relative.isAbsolute()
+ || relative.getNameCount() != 1 || relative.toString().isBlank()) {
+ throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ }
+ for (Path component : relative) {
+ String name = component.toString();
+ if (name.isBlank() || name.equals(".") || name.equals("..")) {
+ throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ }
+ }
+ bindings.put(reference, new Binding(null, relative, version, expiresAt));
+ return this;
+ }
+
+ public BoundSecretProvider build() {
+ return new BoundSecretProvider(root, rootFileKey, bindings, environment);
+ }
+ }
+}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/secret/VaultKv2SecretProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/secret/VaultKv2SecretProvider.java
new file mode 100644
index 00000000..23a4e5ca
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/secret/VaultKv2SecretProvider.java
@@ -0,0 +1,181 @@
+package io.teaql.runtime.secret;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.teaql.core.UserContext;
+import io.teaql.core.http.HttpRequestService;
+import io.teaql.core.secret.SecretFailure;
+import io.teaql.core.secret.SecretProvider;
+import io.teaql.core.secret.SecretReference;
+import io.teaql.core.secret.SecretValue;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.time.DateTimeException;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/** Explicit Vault KV v2 adapter. Profiles and secret paths are bound at trusted startup. */
+public final class VaultKv2SecretProvider implements SecretProvider {
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private static final int MAX_RESPONSE_BYTES = 64 * 1024;
+
+ private record Binding(String path, String field) {}
+
+ private final String httpProfile;
+ private final String mount;
+ private final SecretProvider tokenProvider;
+ private final SecretReference tokenReference;
+ private final Map bindings;
+
+ private VaultKv2SecretProvider(Builder builder) {
+ httpProfile = builder.httpProfile;
+ mount = builder.mount;
+ tokenProvider = builder.tokenProvider;
+ tokenReference = builder.tokenReference;
+ bindings = Map.copyOf(builder.bindings);
+ }
+
+ public static Builder builder(String httpProfile, String mount,
+ SecretProvider tokenProvider, SecretReference tokenReference) {
+ return new Builder(httpProfile, mount, tokenProvider, tokenReference);
+ }
+
+ @Override public SecretValue resolve(UserContext context, SecretReference reference) {
+ if (context == null || reference == null) {
+ throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ }
+ Binding binding = bindings.get(reference);
+ if (binding == null) throw new SecretFailure(SecretFailure.Code.UNKNOWN_BINDING);
+ try {
+ HttpRequestService http = context.httpRequests();
+ if (!http.profileIsEncryptedOrLoopback(httpProfile)) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ SecretValue credential = tokenProvider.resolve(context, tokenReference);
+ if (credential == null) throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ String token = decodeToken(credential.exposeBytes());
+ HttpRequestService.Call call = new HttpRequestService.Call(
+ httpProfile, HttpRequestService.Method.GET,
+ "v1/" + mount + "/data/" + binding.path(),
+ Map.of("X-Vault-Token", token), null, null,
+ "read configured Vault secret", HttpRequestService.IntentKind.PURPOSE,
+ "resolve runtime credential", HttpRequestService.RetryMode.NONE);
+ HttpRequestService.Response response = http.execute(context, call);
+ if (response == null) throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ return switch (response.status()) {
+ case 200 -> parse(response.body(), binding.field());
+ case 401, 403 -> throw new SecretFailure(SecretFailure.Code.DENIED);
+ case 404 -> throw new SecretFailure(SecretFailure.Code.MISSING);
+ default -> throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ };
+ } catch (SecretFailure safe) {
+ throw safe;
+ } catch (RuntimeException unsafe) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ }
+
+ private static String decodeToken(byte[] bytes) {
+ try {
+ String token = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes)).toString();
+ if (token.isEmpty() || token.chars().anyMatch(c -> Character.isISOControl(c))) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ return token;
+ } catch (CharacterCodingException invalid) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ }
+
+ private static SecretValue parse(byte[] body, String field) {
+ if (body.length > MAX_RESPONSE_BYTES) throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ try {
+ JsonNode document = JSON.readTree(body);
+ JsonNode data = document.path("data");
+ JsonNode metadata = data.path("metadata");
+ JsonNode version = metadata.path("version");
+ JsonNode destroyed = metadata.path("destroyed");
+ JsonNode deletionTime = metadata.path("deletion_time");
+ if (!version.isIntegralNumber() || !version.canConvertToLong()
+ || version.longValue() <= 0 || !destroyed.isBoolean()
+ || !deletionTime.isTextual()) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ if (destroyed.booleanValue()) throw new SecretFailure(SecretFailure.Code.MISSING);
+ Instant expiresAt = deletionTime.textValue().isEmpty()
+ ? null : Instant.parse(deletionTime.textValue());
+ JsonNode lease = document.path("lease_duration");
+ if (!lease.isMissingNode() && (!lease.isIntegralNumber() || !lease.canConvertToLong()
+ || lease.longValue() < 0)) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ if (!lease.isMissingNode() && lease.longValue() > 0) {
+ Instant leaseExpiry = Instant.now().plusSeconds(lease.longValue());
+ expiresAt = expiresAt == null || leaseExpiry.isBefore(expiresAt)
+ ? leaseExpiry : expiresAt;
+ }
+ JsonNode value = data.path("data").path(field);
+ if (!value.isTextual()) throw new SecretFailure(SecretFailure.Code.MISSING);
+ byte[] bytes = value.textValue().getBytes(StandardCharsets.UTF_8);
+ if (bytes.length == 0 || bytes.length > SecretValue.MAX_BYTES) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ return new SecretValue(bytes, "vault-kv2:" + version.longValue(), expiresAt);
+ } catch (SecretFailure safe) {
+ throw safe;
+ } catch (java.io.IOException | DateTimeException | ArithmeticException invalid) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ }
+
+ public static final class Builder {
+ private final String httpProfile;
+ private final String mount;
+ private final SecretProvider tokenProvider;
+ private final SecretReference tokenReference;
+ private final Map bindings = new HashMap<>();
+
+ private Builder(String httpProfile, String mount,
+ SecretProvider tokenProvider, SecretReference tokenReference) {
+ if (!safeName(httpProfile) || !safeName(mount)) {
+ throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ }
+ this.httpProfile = httpProfile;
+ this.mount = mount;
+ this.tokenProvider = Objects.requireNonNull(tokenProvider);
+ this.tokenReference = Objects.requireNonNull(tokenReference);
+ }
+
+ public Builder bind(SecretReference reference, String path, String field) {
+ if (reference == null || !validPath(path) || !safeName(field)) {
+ throw new SecretFailure(SecretFailure.Code.INVALID_INPUT);
+ }
+ bindings.put(reference, new Binding(path, field));
+ return this;
+ }
+
+ public VaultKv2SecretProvider build() {
+ return new VaultKv2SecretProvider(this);
+ }
+ }
+
+ private static boolean safeName(String value) {
+ return value != null && value.matches("[A-Za-z][A-Za-z0-9_-]{0,63}");
+ }
+
+ private static boolean validPath(String path) {
+ if (path == null || path.isEmpty() || path.length() > 256) return false;
+ for (String segment : path.split("/", -1)) {
+ if (segment.isEmpty() || segment.equals(".") || segment.equals("..")
+ || !segment.matches("[A-Za-z0-9_.-]+")) return false;
+ }
+ return true;
+ }
+}
diff --git a/teaql-runtime/src/main/java/module-info.java b/teaql-runtime/src/main/java/module-info.java
index d4858673..a43c5fa4 100644
--- a/teaql-runtime/src/main/java/module-info.java
+++ b/teaql-runtime/src/main/java/module-info.java
@@ -8,6 +8,7 @@
exports io.teaql.runtime.boot;
exports io.teaql.runtime.businessid;
exports io.teaql.runtime.reference;
+ exports io.teaql.runtime.secret;
provides io.teaql.core.spi.ContextAssembler with io.teaql.runtime.boot.CoreAssembler;
uses io.teaql.runtime.StandardTelemetryFactory;
diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java
new file mode 100644
index 00000000..862971c7
--- /dev/null
+++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java
@@ -0,0 +1,192 @@
+package io.teaql.runtime.secret;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.teaql.core.secret.SecretFailure;
+import io.teaql.core.secret.SecretProvider;
+import io.teaql.core.secret.SecretReference;
+import io.teaql.core.secret.SecretValue;
+import io.teaql.runtime.DefaultUserContext;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import io.teaql.core.businessid.BusinessClock;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import static org.junit.Assert.*;
+
+public class BoundSecretProviderTest {
+ @Rule public TemporaryFolder temporary = new TemporaryFolder();
+
+ @Test public void contextBindingAndSafeSerialization() throws Exception {
+ SecretReference reference = new SecretReference("signing", "current");
+ DefaultUserContext context = new DefaultUserContext(null);
+ assertCode(SecretFailure.Code.PROVIDER_NOT_CONFIGURED,
+ () -> context.resolveSecret(reference, "read signing key"));
+ BoundSecretProvider provider = BoundSecretProvider.testBuilder(null,
+ variable -> variable.equals("TEST_KEY") ? "canary-do-not-log" : null)
+ .environment(reference, "TEST_KEY", "v1", null).build();
+ context.withSecretProvider(provider);
+ assertCode(SecretFailure.Code.INVALID_INPUT,
+ () -> context.resolveSecret(reference, " "));
+ SecretValue value = context.resolveSecret(reference, "read signing key");
+ assertEquals("canary-do-not-log", new String(value.exposeBytes()));
+ assertEquals("v1", value.version());
+ assertFalse(value.toString().contains("canary"));
+ Exception error = assertThrows(Exception.class,
+ () -> new ObjectMapper().writeValueAsString(value));
+ assertFalse(error.toString().contains("canary"));
+ assertCode(SecretFailure.Code.UNKNOWN_BINDING,
+ () -> context.resolveSecret(new SecretReference("signing", "absent"), "read"));
+ assertCode(SecretFailure.Code.INVALID_INPUT,
+ () -> new SecretReference("signing", "../key"));
+ assertCode(SecretFailure.Code.INVALID_INPUT,
+ () -> new SecretReference("signing", "/tmp/key"));
+
+ CountDownLatch entered = new CountDownLatch(2);
+ SecretProvider custom = (caller, requested) -> {
+ entered.countDown();
+ boolean bothEntered;
+ try {
+ bothEntered = entered.await(2, TimeUnit.SECONDS);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ if (!bothEntered) {
+ throw new SecretFailure(SecretFailure.Code.UNAVAILABLE);
+ }
+ return new SecretValue("custom".getBytes(), null, null);
+ };
+ DefaultUserContext second = new DefaultUserContext(null).withSecretProvider(custom);
+ DefaultUserContext third = new DefaultUserContext(null).withSecretProvider(custom);
+ second.putAttribute("test.marker", "second");
+ third.putAttribute("test.marker", "third");
+ var executor = Executors.newFixedThreadPool(2);
+ try {
+ var twoCalls = executor.submit(
+ () -> new String(second.resolveSecret(reference, "second context").exposeBytes()));
+ var threeCalls = executor.submit(
+ () -> new String(third.resolveSecret(reference, "third context").exposeBytes()));
+ assertEquals("custom", twoCalls.get(3, TimeUnit.SECONDS));
+ assertEquals("custom", threeCalls.get(3, TimeUnit.SECONDS));
+ } finally {
+ executor.shutdownNow();
+ }
+ assertEquals("second", second.getAttribute("test.marker"));
+ assertEquals("third", third.getAttribute("test.marker"));
+ assertEquals("canary-do-not-log", new String(context.resolveSecret(reference, "first provider").exposeBytes()));
+
+ DefaultUserContext unsafe = new DefaultUserContext(null).withSecretProvider(
+ (caller, requested) -> { throw new IllegalStateException("canary-do-not-log"); });
+ SecretFailure sanitized = assertThrows(SecretFailure.class,
+ () -> unsafe.resolveSecret(reference, "read"));
+ assertEquals(SecretFailure.Code.UNAVAILABLE, sanitized.code());
+ assertNull(sanitized.getCause());
+ assertFalse(sanitized.toString().contains("canary"));
+ DefaultUserContext absent = new DefaultUserContext(null).withSecretProvider(
+ (caller, requested) -> null);
+ assertCode(SecretFailure.Code.UNAVAILABLE,
+ () -> absent.resolveSecret(reference, "read"));
+ }
+
+ @Test public void fileRotationPermissionsAndSymlink() throws Exception {
+ Path root = temporary.newFolder("keys").toPath();
+ org.junit.Assume.assumeTrue(Files.getFileStore(root).supportsFileAttributeView("posix"));
+ Files.setPosixFilePermissions(root, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE));
+ Path key = root.resolve("key");
+ Files.writeString(key, "first");
+ Files.setPosixFilePermissions(key, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE));
+ SecretReference reference = new SecretReference("signing", "key");
+ BoundSecretProvider.Builder builder = BoundSecretProvider.builder(root);
+ assertCode(SecretFailure.Code.INVALID_INPUT,
+ () -> builder.file(reference, Path.of("../key"), null, null));
+ assertCode(SecretFailure.Code.INVALID_INPUT,
+ () -> builder.file(reference, Path.of("nested/key"), null, null));
+ BoundSecretProvider provider = builder.file(reference, Path.of("key"), "v1", null).build();
+ DefaultUserContext context = new DefaultUserContext(null).withSecretProvider(provider);
+ assertEquals("first", new String(context.resolveSecret(reference, "read").exposeBytes()));
+ Files.writeString(key, "second");
+ context.withSecretProvider(BoundSecretProvider.builder(root)
+ .file(reference, Path.of("key"), "v2", null).build());
+ SecretValue rotated = context.resolveSecret(reference, "refresh");
+ assertEquals("second", new String(rotated.exposeBytes()));
+ assertEquals("v2", rotated.version());
+ Files.setPosixFilePermissions(key, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.GROUP_READ));
+ assertCode(SecretFailure.Code.DENIED,
+ () -> context.resolveSecret(reference, "read"));
+ Files.delete(key);
+ Files.createSymbolicLink(key, root.resolve("missing"));
+ assertCode(SecretFailure.Code.DENIED,
+ () -> context.resolveSecret(reference, "read"));
+ Files.delete(key);
+ Files.write(key, new byte[SecretValue.MAX_BYTES + 1]);
+ Files.setPosixFilePermissions(key, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE));
+ assertCode(SecretFailure.Code.DENIED,
+ () -> context.resolveSecret(reference, "oversized file"));
+ Files.setPosixFilePermissions(root, Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE,
+ PosixFilePermission.GROUP_WRITE));
+ assertCode(SecretFailure.Code.DENIED,
+ () -> context.resolveSecret(reference, "read after root permission change"));
+ assertCode(SecretFailure.Code.DENIED,
+ () -> BoundSecretProvider.builder(root));
+ }
+
+ @Test public void expiryUsesOperationalTime() {
+ SecretReference reference = new SecretReference("signing", "key");
+ BoundSecretProvider provider = BoundSecretProvider.testBuilder(null,
+ ignored -> "canary")
+ .environment(reference, "TEST_KEY", null, Instant.EPOCH).build();
+ DefaultUserContext context = new DefaultUserContext(null).withSecretProvider(provider);
+ context.putAttribute(BusinessClock.class.getName(),
+ (BusinessClock) caller -> LocalDate.of(1900, 1, 1));
+ assertCode(SecretFailure.Code.EXPIRED,
+ () -> context.resolveSecret(reference, "read"));
+ }
+
+ @Test public void replacingBoundRootWithAnotherPrivateDirectoryFailsClosed() throws Exception {
+ Path root = temporary.newFolder("bound-root").toPath();
+ org.junit.Assume.assumeTrue(Files.getFileStore(root).supportsFileAttributeView("posix"));
+ Set privateDirectory = Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
+ Set privateFile = Set.of(PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE);
+ Files.setPosixFilePermissions(root, privateDirectory);
+ Path key = root.resolve("key");
+ Files.writeString(key, "original");
+ Files.setPosixFilePermissions(key, privateFile);
+ SecretReference reference = new SecretReference("signing", "key");
+ DefaultUserContext context = new DefaultUserContext(null).withSecretProvider(
+ BoundSecretProvider.builder(root).file(reference, Path.of("key"), "v1", null).build());
+ assertEquals("original", new String(context.resolveSecret(reference, "read").exposeBytes()));
+
+ Files.move(root, root.resolveSibling("parked-bound-root"));
+ Files.createDirectory(root);
+ Files.setPosixFilePermissions(root, privateDirectory);
+ Files.writeString(key, "replacement");
+ Files.setPosixFilePermissions(key, privateFile);
+ assertCode(SecretFailure.Code.DENIED,
+ () -> context.resolveSecret(reference, "read after root replacement"));
+ Files.delete(key);
+ Files.delete(root);
+ Files.createSymbolicLink(root, root.resolveSibling("parked-bound-root"));
+ assertCode(SecretFailure.Code.DENIED,
+ () -> context.resolveSecret(reference, "read through replacement symlink"));
+ Files.delete(root);
+ }
+
+ private static void assertCode(SecretFailure.Code expected, Runnable action) {
+ assertEquals(expected, assertThrows(SecretFailure.class, action::run).code());
+ }
+}
diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java
new file mode 100644
index 00000000..6960bb42
--- /dev/null
+++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java
@@ -0,0 +1,238 @@
+package io.teaql.runtime.secret;
+
+import io.teaql.core.http.HttpRequestFailure;
+import io.teaql.core.http.HttpRequestService;
+import io.teaql.core.secret.SecretFailure;
+import io.teaql.core.secret.SecretProvider;
+import io.teaql.core.secret.SecretReference;
+import io.teaql.core.secret.SecretValue;
+import io.teaql.runtime.DefaultUserContext;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class VaultKv2SecretProviderTest {
+ private static final SecretReference REFERENCE = new SecretReference("payments", "api_key");
+ private static final SecretReference TOKEN_REFERENCE = new SecretReference("bootstrap", "vault_token");
+ private static final String BODY = "{\"data\":{\"data\":{\"value\":\"PRIVATE-VAULT-VALUE\"},"
+ + "\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"\"}}}";
+ private static final SecretProvider TOKEN = (context, reference) ->
+ new SecretValue("PRIVATE-VAULT-TOKEN".getBytes(StandardCharsets.UTF_8), null, null);
+
+ private VaultKv2SecretProvider provider() {
+ return VaultKv2SecretProvider.builder("vault", "secret", TOKEN, TOKEN_REFERENCE)
+ .bind(REFERENCE, "payments/api", "value").build();
+ }
+
+ private DefaultUserContext context(VaultKv2SecretProvider provider, HttpRequestService http) {
+ DefaultUserContext context = new DefaultUserContext(null).withSecretProvider(provider);
+ context.putAttribute(HttpRequestService.class.getName(), new HttpRequestService() {
+ @Override public boolean profileIsEncryptedOrLoopback(String profile) {
+ return "vault".equals(profile);
+ }
+ @Override public Response execute(io.teaql.core.UserContext caller, Call call) {
+ return http.execute(caller, call);
+ }
+ });
+ return context;
+ }
+
+ @Test public void insecureProfileCannotResolveBootstrapTokenOrSendRequest() {
+ AtomicInteger tokenResolutions = new AtomicInteger();
+ AtomicInteger httpCalls = new AtomicInteger();
+ SecretProvider token = (caller, reference) -> {
+ tokenResolutions.incrementAndGet();
+ return new SecretValue("PRIVATE-VAULT-TOKEN".getBytes(StandardCharsets.UTF_8), null, null);
+ };
+ VaultKv2SecretProvider provider = VaultKv2SecretProvider.builder(
+ "vault", "secret", token, TOKEN_REFERENCE)
+ .bind(REFERENCE, "payments/api", "value").build();
+ DefaultUserContext context = new DefaultUserContext(null).withSecretProvider(provider);
+ context.putAttribute(HttpRequestService.class.getName(), (HttpRequestService) (caller, call) -> {
+ httpCalls.incrementAndGet();
+ return new HttpRequestService.Response(200, BODY.getBytes(StandardCharsets.UTF_8), 1);
+ });
+ assertCode(SecretFailure.Code.UNAVAILABLE,
+ () -> context.resolveSecret(REFERENCE, "read payment credential"));
+ assertEquals(0, tokenResolutions.get());
+ assertEquals(0, httpCalls.get());
+ }
+
+ @Test public void contextResolutionUsesBoundPathAndMasksDiagnostics() {
+ AtomicReference captured = new AtomicReference<>();
+ HttpRequestService http = (context, call) -> {
+ captured.set(call);
+ return new HttpRequestService.Response(200, BODY.getBytes(StandardCharsets.UTF_8), 1);
+ };
+ DefaultUserContext context = context(provider(), http);
+ SecretValue value = context.resolveSecret(REFERENCE, "read payment credential");
+ assertEquals("PRIVATE-VAULT-VALUE", new String(value.exposeBytes(), StandardCharsets.UTF_8));
+ assertEquals("vault-kv2:7", value.version());
+ assertFalse(value.toString().contains("PRIVATE-VAULT-VALUE"));
+ HttpRequestService.Call call = captured.get();
+ assertEquals("vault", call.profile());
+ assertEquals("v1/secret/data/payments/api", call.relativePath());
+ assertEquals("PRIVATE-VAULT-TOKEN", call.headers().get("X-Vault-Token"));
+ assertEquals(HttpRequestService.RetryMode.NONE, call.retryMode());
+ assertFalse(call.toString().contains("PRIVATE-VAULT-TOKEN"));
+ assertCode(SecretFailure.Code.UNKNOWN_BINDING,
+ () -> context.resolveSecret(new SecretReference("payments", "unknown"), "read"));
+ }
+
+ @Test public void bootstrapCredentialRotationIsReadOnEveryCallAndExpiryFailsBeforeTransport() {
+ AtomicReference currentToken = new AtomicReference<>("PRIVATE-VAULT-TOKEN-A");
+ AtomicInteger tokenResolutions = new AtomicInteger();
+ AtomicInteger httpCalls = new AtomicInteger();
+ SecretProvider rotatingToken = (caller, reference) -> {
+ tokenResolutions.incrementAndGet();
+ if ("expired".equals(currentToken.get())) {
+ throw new SecretFailure(SecretFailure.Code.EXPIRED);
+ }
+ return new SecretValue(currentToken.get().getBytes(StandardCharsets.UTF_8), null, null);
+ };
+ VaultKv2SecretProvider provider = VaultKv2SecretProvider.builder(
+ "vault", "secret", rotatingToken, TOKEN_REFERENCE)
+ .bind(REFERENCE, "payments/api", "value").build();
+ HttpRequestService http = (caller, call) -> {
+ httpCalls.incrementAndGet();
+ String token = call.headers().get("X-Vault-Token");
+ String body = switch (token) {
+ case "PRIVATE-VAULT-TOKEN-A" -> BODY;
+ case "PRIVATE-VAULT-TOKEN-B" -> BODY.replace("\"version\":7", "\"version\":8")
+ .replace("PRIVATE-VAULT-VALUE", "ROTATED-VAULT-VALUE");
+ default -> throw new AssertionError("unexpected bootstrap token");
+ };
+ return new HttpRequestService.Response(200, body.getBytes(StandardCharsets.UTF_8), 1);
+ };
+ DefaultUserContext context = context(provider, http);
+ assertEquals("vault-kv2:7", context.resolveSecret(REFERENCE, "read before rotation").version());
+ currentToken.set("PRIVATE-VAULT-TOKEN-B");
+ SecretValue rotated = context.resolveSecret(REFERENCE, "read after rotation");
+ assertEquals("vault-kv2:8", rotated.version());
+ assertEquals("ROTATED-VAULT-VALUE",
+ new String(rotated.exposeBytes(), StandardCharsets.UTF_8));
+ assertEquals(2, tokenResolutions.get());
+ assertEquals(2, httpCalls.get());
+
+ currentToken.set("expired");
+ assertCode(SecretFailure.Code.EXPIRED,
+ () -> context.resolveSecret(REFERENCE, "reject expired bootstrap token"));
+ assertEquals(3, tokenResolutions.get());
+ assertEquals("expired token must not reach HTTP", 2, httpCalls.get());
+ }
+
+ @Test public void sharedProviderKeepsConcurrentContextHttpRequestsIsolated() throws Exception {
+ SecretReference alpha = new SecretReference("payments", "alpha");
+ SecretReference beta = new SecretReference("payments", "beta");
+ SecretProvider perContextToken = (caller, reference) -> {
+ String actor = (String) caller.getAttribute("test.actor");
+ String token = "alpha".equals(actor) ? "PRIVATE-VAULT-TOKEN-A"
+ : "PRIVATE-VAULT-TOKEN-B";
+ return new SecretValue(token.getBytes(StandardCharsets.UTF_8), null, null);
+ };
+ VaultKv2SecretProvider sharedProvider = VaultKv2SecretProvider.builder(
+ "vault", "secret", perContextToken, TOKEN_REFERENCE)
+ .bind(alpha, "payments/alpha", "value")
+ .bind(beta, "payments/beta", "value")
+ .build();
+ CountDownLatch bothInHttp = new CountDownLatch(2);
+ ConcurrentLinkedQueue observed = new ConcurrentLinkedQueue<>();
+ HttpRequestService http = (caller, call) -> {
+ String actor = (String) caller.getAttribute("test.actor");
+ String suffix = "alpha".equals(actor) ? "A" : "B";
+ assertEquals("v1/secret/data/payments/" + actor, call.relativePath());
+ assertEquals("PRIVATE-VAULT-TOKEN-" + suffix,
+ call.headers().get("X-Vault-Token"));
+ bothInHttp.countDown();
+ try {
+ assertTrue("both Context requests must overlap",
+ bothInHttp.await(5, TimeUnit.SECONDS));
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError(interrupted);
+ }
+ assertEquals("v1/secret/data/payments/" + actor, call.relativePath());
+ assertEquals("PRIVATE-VAULT-TOKEN-" + suffix,
+ call.headers().get("X-Vault-Token"));
+ observed.add(actor + ":" + suffix);
+ String body = BODY.replace("PRIVATE-VAULT-VALUE", "VALUE-" + suffix);
+ return new HttpRequestService.Response(200,
+ body.getBytes(StandardCharsets.UTF_8), 1);
+ };
+ DefaultUserContext contextA = context(sharedProvider, http);
+ DefaultUserContext contextB = context(sharedProvider, http);
+ contextA.putAttribute("test.actor", "alpha");
+ contextB.putAttribute("test.actor", "beta");
+
+ ExecutorService workers = Executors.newFixedThreadPool(2);
+ try {
+ Future first = workers.submit(
+ () -> contextA.resolveSecret(alpha, "read alpha credential"));
+ Future second = workers.submit(
+ () -> contextB.resolveSecret(beta, "read beta credential"));
+ assertEquals("VALUE-A", new String(first.get(10, TimeUnit.SECONDS).exposeBytes(),
+ StandardCharsets.UTF_8));
+ assertEquals("VALUE-B", new String(second.get(10, TimeUnit.SECONDS).exposeBytes(),
+ StandardCharsets.UTF_8));
+ } finally {
+ workers.shutdownNow();
+ }
+ assertEquals(2, observed.size());
+ assertTrue(observed.contains("alpha:A"));
+ assertTrue(observed.contains("beta:B"));
+ }
+
+ @Test public void failuresAreSafeAndDistinct() {
+ for (int status : new int[] {403, 404, 500}) {
+ HttpRequestService http = (context, call) -> new HttpRequestService.Response(
+ status, "PRIVATE-VAULT-VALUE".getBytes(StandardCharsets.UTF_8), 1);
+ SecretFailure.Code expected = status == 403 ? SecretFailure.Code.DENIED
+ : status == 404 ? SecretFailure.Code.MISSING : SecretFailure.Code.UNAVAILABLE;
+ assertCode(expected, () -> context(provider(), http).resolveSecret(REFERENCE, "read"));
+ }
+ HttpRequestService invalid = (context, call) -> new HttpRequestService.Response(200,
+ "PRIVATE-VAULT-VALUE".getBytes(StandardCharsets.UTF_8), 1);
+ assertCode(SecretFailure.Code.UNAVAILABLE,
+ () -> context(provider(), invalid).resolveSecret(REFERENCE, "read"));
+ HttpRequestService timeout = (context, call) -> {
+ throw new HttpRequestFailure(HttpRequestFailure.Code.TIMEOUT);
+ };
+ assertCode(SecretFailure.Code.UNAVAILABLE,
+ () -> context(provider(), timeout).resolveSecret(REFERENCE, "read"));
+ String deleted = BODY.replace("\"deletion_time\":\"\"",
+ "\"deletion_time\":\"2020-01-01T00:00:00Z\"");
+ HttpRequestService expired = (context, call) -> new HttpRequestService.Response(200,
+ deleted.getBytes(StandardCharsets.UTF_8), 1);
+ assertCode(SecretFailure.Code.EXPIRED,
+ () -> context(provider(), expired).resolveSecret(REFERENCE, "read"));
+ String destroyedBody = BODY.replace("\"destroyed\":false", "\"destroyed\":true");
+ HttpRequestService destroyed = (context, call) -> new HttpRequestService.Response(200,
+ destroyedBody.getBytes(StandardCharsets.UTF_8), 1);
+ assertCode(SecretFailure.Code.MISSING,
+ () -> context(provider(), destroyed).resolveSecret(REFERENCE, "read"));
+ }
+
+ @Test public void untrustedPathsAreRejectedAtBindingTime() {
+ for (String path : new String[] {"../key", "/key", "key?version=1", "key%2Fother",
+ "key\\other", "a//b"}) {
+ assertCode(SecretFailure.Code.INVALID_INPUT,
+ () -> VaultKv2SecretProvider.builder("vault", "secret", TOKEN, TOKEN_REFERENCE)
+ .bind(REFERENCE, path, "value"));
+ }
+ }
+
+ private static void assertCode(SecretFailure.Code expected, Runnable action) {
+ SecretFailure failure = assertThrows(SecretFailure.class, action::run);
+ assertEquals(expected, failure.code());
+ assertNull(failure.getCause());
+ assertFalse(failure.toString().contains("PRIVATE-VAULT"));
+ }
+}
diff --git a/teaql-tool-http/src/main/java/io/teaql/tools/http/HttpServiceProfile.java b/teaql-tool-http/src/main/java/io/teaql/tools/http/HttpServiceProfile.java
index 00e6da37..5495abdb 100644
--- a/teaql-tool-http/src/main/java/io/teaql/tools/http/HttpServiceProfile.java
+++ b/teaql-tool-http/src/main/java/io/teaql/tools/http/HttpServiceProfile.java
@@ -1,9 +1,14 @@
package io.teaql.tools.http;
import io.teaql.core.http.HttpRequestService;
+import java.io.ByteArrayInputStream;
import java.net.URI;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.EnumSet;
+import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -11,6 +16,7 @@
public final class HttpServiceProfile {
private final String alias;
private final URI baseUri;
+ private final List trustedRoots;
private final Duration connectTimeout;
private final Duration requestTimeout;
private final Duration totalTimeout;
@@ -26,6 +32,7 @@ public final class HttpServiceProfile {
private HttpServiceProfile(Builder b) {
this.alias = b.alias;
this.baseUri = b.baseUri;
+ this.trustedRoots = b.trustedRoots;
this.connectTimeout = b.connectTimeout;
this.requestTimeout = b.requestTimeout;
this.totalTimeout = b.totalTimeout;
@@ -42,6 +49,7 @@ private HttpServiceProfile(Builder b) {
public static Builder builder(String alias, URI baseUri) { return new Builder(alias, baseUri); }
public String alias() { return alias; }
public URI baseUri() { return baseUri; }
+ List trustedRoots() { return trustedRoots; }
public Duration connectTimeout() { return connectTimeout; }
public Duration requestTimeout() { return requestTimeout; }
public Duration totalTimeout() { return totalTimeout; }
@@ -58,6 +66,7 @@ private HttpServiceProfile(Builder b) {
public static final class Builder {
private final String alias;
private final URI baseUri;
+ private List trustedRoots = List.of();
private Duration connectTimeout = Duration.ofSeconds(2);
private Duration requestTimeout = Duration.ofSeconds(5);
private Duration totalTimeout = Duration.ofSeconds(10);
@@ -94,6 +103,39 @@ public Builder budgets(Duration connect, Duration request, Duration total) {
return this;
}
public Builder maxResponseBytes(int bytes) { this.maxResponseBytes = bytes; return this; }
+ /** Replace this profile's custom trust set with exactly one PEM root. */
+ public Builder trustedRootPem(byte[] pem) {
+ if (pem == null) throw new IllegalArgumentException("invalid trusted TLS root");
+ return trustedRootsPem(List.of(pem));
+ }
+
+ /** Explicit short overlap for CA rotation; custom roots replace system roots for this profile. */
+ public Builder trustedRootsPem(List pems) {
+ if (!"https".equalsIgnoreCase(baseUri.getScheme()) || pems == null
+ || pems.isEmpty() || pems.size() > 4) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ try {
+ var parsed = new ArrayList(pems.size());
+ var factory = CertificateFactory.getInstance("X.509");
+ for (byte[] pem : pems) {
+ if (pem == null || pem.length == 0 || pem.length > 64 * 1024) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ var certificates = factory.generateCertificates(new ByteArrayInputStream(pem.clone()));
+ if (certificates.size() != 1
+ || !(certificates.iterator().next() instanceof X509Certificate root)
+ || parsed.contains(root)) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ parsed.add(root);
+ }
+ trustedRoots = List.copyOf(parsed);
+ } catch (java.security.cert.CertificateException failure) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ return this;
+ }
public Builder retry(int attempts, Duration delay, Set methods) {
this.maxAttempts = attempts;
this.retryDelay = positive(delay);
diff --git a/teaql-tool-http/src/main/java/io/teaql/tools/http/JdkHttpRequestService.java b/teaql-tool-http/src/main/java/io/teaql/tools/http/JdkHttpRequestService.java
index 81ff9949..11279bfc 100644
--- a/teaql-tool-http/src/main/java/io/teaql/tools/http/JdkHttpRequestService.java
+++ b/teaql-tool-http/src/main/java/io/teaql/tools/http/JdkHttpRequestService.java
@@ -12,6 +12,8 @@
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
@@ -24,6 +26,8 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Logger;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
/** Shared JDK transport for startup registered HTTP profiles. */
public final class JdkHttpRequestService implements HttpRequestService {
@@ -48,6 +52,17 @@ public JdkHttpRequestService(List profiles, HttpOperationalC
this.profiles = Map.copyOf(states);
}
+ @Override
+ public boolean profileIsEncryptedOrLoopback(String alias) {
+ State state = profiles.get(alias);
+ if (state == null) return false;
+ URI base = state.profile.baseUri();
+ if ("https".equalsIgnoreCase(base.getScheme())) return true;
+ if (!"http".equalsIgnoreCase(base.getScheme())) return false;
+ String host = base.getHost();
+ return "127.0.0.1".equals(host) || "::1".equals(host) || "[::1]".equals(host);
+ }
+
@Override
public Response execute(UserContext context, Call call) {
if (context == null || call == null) throw new HttpRequestFailure(HttpRequestFailure.Code.INVALID_INPUT);
@@ -265,11 +280,30 @@ private static final class State {
this.profile = profile;
this.clock = clock;
this.refilledAt = clock.nanoTime();
- this.client = HttpClient.newBuilder()
+ HttpClient.Builder builder = HttpClient.newBuilder()
.connectTimeout(profile.connectTimeout())
- .followRedirects(HttpClient.Redirect.NEVER).build();
+ .followRedirects(HttpClient.Redirect.NEVER);
+ if (!profile.trustedRoots().isEmpty()) builder.sslContext(trustedRootContext(profile));
+ this.client = builder.build();
this.tokens = profile.ratePerSecond();
}
+ private static SSLContext trustedRootContext(HttpServiceProfile profile) {
+ try {
+ KeyStore store = KeyStore.getInstance("PKCS12");
+ store.load(null, null);
+ for (int index = 0; index < profile.trustedRoots().size(); index++) {
+ store.setCertificateEntry("root-" + index, profile.trustedRoots().get(index));
+ }
+ TrustManagerFactory managers = TrustManagerFactory.getInstance(
+ TrustManagerFactory.getDefaultAlgorithm());
+ managers.init(store);
+ SSLContext context = SSLContext.getInstance("TLS");
+ context.init(null, managers.getTrustManagers(), null);
+ return context;
+ } catch (GeneralSecurityException | java.io.IOException failure) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ }
synchronized boolean acquireCircuit() {
if (profile.circuitFailures() == 0) return false;
if (openUntil == 0) return false;
diff --git a/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpResilienceTest.java b/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpResilienceTest.java
index 6c66e1ac..a0b4e2c2 100644
--- a/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpResilienceTest.java
+++ b/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpResilienceTest.java
@@ -48,6 +48,28 @@ private HttpServiceProfile.Builder profile() {
return HttpServiceProfile.builder("orders", URI.create("http://127.0.0.1:"
+ server.port() + "/api/"));
}
+
+ @Test public void sensitiveProfileAttestationAcceptsTlsOrLiteralLoopbackOnly() {
+ JdkHttpRequestService service = new JdkHttpRequestService(java.util.List.of(
+ HttpServiceProfile.builder("tls", URI.create("https://replica.example.test/api/")).build(),
+ HttpServiceProfile.builder("ipv4", URI.create("http://127.0.0.1:8080/api/")).build(),
+ HttpServiceProfile.builder("ipv6", URI.create("http://[::1]:8080/api/")).build(),
+ HttpServiceProfile.builder("remote_http", URI.create("http://replica.example.test/api/")).build(),
+ HttpServiceProfile.builder("named_local", URI.create("http://localhost:8080/api/")).build()));
+ for (String alias : java.util.List.of("tls", "ipv4", "ipv6")) {
+ assertTrue(service.profileIsEncryptedOrLoopback(alias));
+ }
+ for (String alias : java.util.List.of("remote_http", "named_local", "missing")) {
+ assertFalse(service.profileIsEncryptedOrLoopback(alias));
+ }
+ }
+ @Test public void profileTrustRootIsHttpsOnlyAndRejectsMalformedPem() {
+ assertThrows(IllegalArgumentException.class,
+ () -> profile().trustedRootPem("not a certificate".getBytes(StandardCharsets.US_ASCII)));
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpServiceProfile.builder("vault", URI.create("https://127.0.0.1/"))
+ .trustedRootPem("not a certificate".getBytes(StandardCharsets.US_ASCII)));
+ }
private UserContext context(HttpRequestService service) {
return context(service, null);
}