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