From e97ed503eb73d2c2a0d57d017c7af6fc75696d69 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 03:07:20 +0800 Subject: [PATCH 01/29] Add context-bound Java secret provider baseline --- examples/secret-provider/README.md | 7 + examples/secret-provider/pom.xml | 9 + .../secret/SecretProviderExampleTest.java | 47 +++++ examples/secret-provider/verify.sh | 16 ++ pom.xml | 1 + .../main/java/io/teaql/core/UserContext.java | 28 +++ .../io/teaql/core/secret/SecretFailure.java | 20 ++ .../io/teaql/core/secret/SecretProvider.java | 8 + .../io/teaql/core/secret/SecretReference.java | 14 ++ .../io/teaql/core/secret/SecretValue.java | 56 ++++++ teaql-core/src/main/java/module-info.java | 1 + .../io/teaql/runtime/DefaultUserContext.java | 7 + .../runtime/secret/BoundSecretProvider.java | 171 ++++++++++++++++++ teaql-runtime/src/main/java/module-info.java | 1 + .../secret/BoundSecretProviderTest.java | 113 ++++++++++++ 15 files changed, 499 insertions(+) create mode 100644 examples/secret-provider/README.md create mode 100644 examples/secret-provider/pom.xml create mode 100644 examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretProviderExampleTest.java create mode 100644 examples/secret-provider/verify.sh create mode 100644 teaql-core/src/main/java/io/teaql/core/secret/SecretFailure.java create mode 100644 teaql-core/src/main/java/io/teaql/core/secret/SecretProvider.java create mode 100644 teaql-core/src/main/java/io/teaql/core/secret/SecretReference.java create mode 100644 teaql-core/src/main/java/io/teaql/core/secret/SecretValue.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/secret/BoundSecretProvider.java create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java diff --git a/examples/secret-provider/README.md b/examples/secret-provider/README.md new file mode 100644 index 00000000..3cba4dbe --- /dev/null +++ b/examples/secret-provider/README.md @@ -0,0 +1,7 @@ +# 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; the example runs twice and does not print secret content. +Vault and remote transport are outside this example. diff --git a/examples/secret-provider/pom.xml b/examples/secret-provider/pom.xml new file mode 100644 index 00000000..54b5ba0a --- /dev/null +++ b/examples/secret-provider/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + io.teaqlteaql-java-parent1.555-RELEASE../../pom.xml + teaql-secret-provider-example + + io.teaqlteaql-runtime + junitjunittest + + 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..8e640bb4 --- /dev/null +++ b/examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretProviderExampleTest.java @@ -0,0 +1,47 @@ +package io.teaql.examples.secret; + +import io.teaql.core.secret.SecretFailure; +import io.teaql.core.secret.SecretReference; +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.util.Set; +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(); + + @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); + + 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()); + assertFalse(rotated.toString().contains("private-example-key")); + assertEquals(SecretFailure.Code.INVALID_INPUT, + assertThrows(SecretFailure.class, + () -> context.resolveSecret(reference, " ")).code()); + } +} diff --git a/examples/secret-provider/verify.sh b/examples/secret-provider/verify.sh new file mode 100644 index 00000000..dd65ce45 --- /dev/null +++ b/examples/secret-provider/verify.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo" +for round in 1 2; do + if ! output="$(mvn -B -q -pl examples/secret-provider -am \ + -Dtest=SecretProviderExampleTest -Dsurefire.failIfNoSpecifiedTests=false test 2>&1)"; then + printf 'Java Secret Provider example round %s failed\n%s\n' "$round" "$output" >&2 + exit 1 + fi + if [[ "$output" == *"private-example-key"* ]]; then + printf 'Java Secret Provider example round %s leaked a canary\n' "$round" >&2 + exit 1 + fi + printf 'PASS Java Secret Provider example round %s\n' "$round" +done 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/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/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..7a6eb756 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/secret/SecretValue.java @@ -0,0 +1,56 @@ +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; + private final byte[] bytes; + private final String version; + private final 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-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..839327d2 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/secret/BoundSecretProvider.java @@ -0,0 +1,171 @@ +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.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +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 Map bindings; + private final Function environment; + + private BoundSecretProvider(Path root, Map bindings, + Function environment) { + this.root = root; + 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, binding.file()); + } + return new SecretValue(bytes, binding.version(), binding.expiresAt()); + } + + private static byte[] readFile(Path root, Path relative) { + Path candidate = root; + try { + for (Path component : relative) { + candidate = candidate.resolve(component); + if (Files.isSymbolicLink(candidate)) throw new SecretFailure(SecretFailure.Code.DENIED); + if (!Files.exists(candidate, LinkOption.NOFOLLOW_LINKS)) { + throw new SecretFailure(SecretFailure.Code.MISSING); + } + } + Path actual = candidate.toRealPath(LinkOption.NOFOLLOW_LINKS); + if (!actual.startsWith(root) || !Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS)) { + throw new SecretFailure(SecretFailure.Code.DENIED); + } + if (Files.size(candidate) > SecretValue.MAX_BYTES) throw new SecretFailure(SecretFailure.Code.DENIED); + try { + Set mode = Files.getPosixFilePermissions(candidate, + LinkOption.NOFOLLOW_LINKS); + if (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)) { + throw new SecretFailure(SecretFailure.Code.DENIED); + } + } catch (UnsupportedOperationException ignored) { + // No portable owner-only permission proof: fail closed. + throw new SecretFailure(SecretFailure.Code.DENIED); + } + try (SeekableByteChannel channel = Files.newByteChannel(candidate, + 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); + if (bytes.length > SecretValue.MAX_BYTES) throw new SecretFailure(SecretFailure.Code.DENIED); + return bytes; + } + } catch (SecretFailure failure) { + throw failure; + } catch (IOException failure) { + throw new SecretFailure(SecretFailure.Code.UNAVAILABLE); + } + } + + public static final class Builder { + private final Path root; + 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; + } 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); + } 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, bindings, environment); + } + } +} 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..b0b8f340 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java @@ -0,0 +1,113 @@ +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.util.Set; +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")); + + SecretProvider custom = (caller, requested) -> + new SecretValue("custom".getBytes(), null, null); + DefaultUserContext second = new DefaultUserContext(null).withSecretProvider(custom); + assertEquals("custom", new String(second.resolveSecret(reference, "custom provider").exposeBytes())); + 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")); + } + + @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); + assertCode(SecretFailure.Code.EXPIRED, + () -> context.resolveSecret(reference, "read")); + } + + private static void assertCode(SecretFailure.Code expected, Runnable action) { + assertEquals(expected, assertThrows(SecretFailure.class, action::run).code()); + } +} From 9d9db8269c5500189a09ef35c6bfb35702393e7b Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 03:09:29 +0800 Subject: [PATCH 02/29] Qualify Java secret expiry and concurrent contexts --- .../secret/BoundSecretProviderTest.java | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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 index b0b8f340..e490cba6 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java @@ -10,7 +10,10 @@ 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.CompletableFuture; +import io.teaql.core.businessid.BusinessClock; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -47,7 +50,17 @@ public class BoundSecretProviderTest { SecretProvider custom = (caller, requested) -> new SecretValue("custom".getBytes(), null, null); DefaultUserContext second = new DefaultUserContext(null).withSecretProvider(custom); - assertEquals("custom", new String(second.resolveSecret(reference, "custom provider").exposeBytes())); + DefaultUserContext third = new DefaultUserContext(null).withSecretProvider(custom); + second.putAttribute("test.marker", "second"); + third.putAttribute("test.marker", "third"); + var twoCalls = CompletableFuture.supplyAsync( + () -> new String(second.resolveSecret(reference, "second context").exposeBytes())); + var threeCalls = CompletableFuture.supplyAsync( + () -> new String(third.resolveSecret(reference, "third context").exposeBytes())); + assertEquals("custom", twoCalls.get()); + assertEquals("custom", threeCalls.get()); + 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( @@ -95,6 +108,17 @@ public class BoundSecretProviderTest { 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, + () -> BoundSecretProvider.builder(root)); } @Test public void expiryUsesOperationalTime() { @@ -103,6 +127,8 @@ public class BoundSecretProviderTest { 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")); } From 0886b12dea43ef5ac9039bcf1889e72eaf670813 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 03:11:39 +0800 Subject: [PATCH 03/29] Prove concurrent Java secret resolution --- .../secret/BoundSecretProviderTest.java | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) 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 index e490cba6..19bfa971 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java @@ -12,7 +12,9 @@ import java.time.Instant; import java.time.LocalDate; import java.util.Set; -import java.util.concurrent.CompletableFuture; +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; @@ -47,18 +49,36 @@ public class BoundSecretProviderTest { assertCode(SecretFailure.Code.INVALID_INPUT, () -> new SecretReference("signing", "/tmp/key")); - SecretProvider custom = (caller, requested) -> - new SecretValue("custom".getBytes(), null, null); + 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 twoCalls = CompletableFuture.supplyAsync( - () -> new String(second.resolveSecret(reference, "second context").exposeBytes())); - var threeCalls = CompletableFuture.supplyAsync( - () -> new String(third.resolveSecret(reference, "third context").exposeBytes())); - assertEquals("custom", twoCalls.get()); - assertEquals("custom", threeCalls.get()); + 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())); From c77cd94b2ee68b77e36c1b07a0dd8a0d7bbba680 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 03:23:59 +0800 Subject: [PATCH 04/29] Add controlled Vault KV v2 secret adapter --- examples/secret-provider/README.md | 9 +- examples/secret-provider/pom.xml | 1 + .../examples/secret/VaultKv2ExampleTest.java | 100 ++++++++++ examples/secret-provider/verify.sh | 4 +- .../secret/VaultKv2SecretProvider.java | 177 ++++++++++++++++++ .../secret/VaultKv2SecretProviderTest.java | 100 ++++++++++ 6 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 examples/secret-provider/src/test/java/io/teaql/examples/secret/VaultKv2ExampleTest.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/secret/VaultKv2SecretProvider.java create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java diff --git a/examples/secret-provider/README.md b/examples/secret-provider/README.md index 3cba4dbe..853d5326 100644 --- a/examples/secret-provider/README.md +++ b/examples/secret-provider/README.md @@ -3,5 +3,10 @@ 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; the example runs twice and does not print secret content. -Vault and remote transport are outside this example. +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. diff --git a/examples/secret-provider/pom.xml b/examples/secret-provider/pom.xml index 54b5ba0a..11b0a294 100644 --- a/examples/secret-provider/pom.xml +++ b/examples/secret-provider/pom.xml @@ -4,6 +4,7 @@ teaql-secret-provider-example io.teaqlteaql-runtime + io.teaqlteaql-tool-http junitjunittest 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..e80b3aa3 --- /dev/null +++ b/examples/secret-provider/src/test/java/io/teaql/examples/secret/VaultKv2ExampleTest.java @@ -0,0 +1,100 @@ +package io.teaql.examples.secret; + +import io.teaql.core.http.HttpRequestService; +import io.teaql.core.secret.SecretFailure; +import io.teaql.core.secret.SecretReference; +import io.teaql.core.secret.SecretValue; +import io.teaql.runtime.DefaultUserContext; +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.time.Duration; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.Test; +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 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 index dd65ce45..f4b7c576 100644 --- a/examples/secret-provider/verify.sh +++ b/examples/secret-provider/verify.sh @@ -4,11 +4,11 @@ repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo" for round in 1 2; do if ! output="$(mvn -B -q -pl examples/secret-provider -am \ - -Dtest=SecretProviderExampleTest -Dsurefire.failIfNoSpecifiedTests=false test 2>&1)"; then + -Dtest=SecretProviderExampleTest,VaultKv2ExampleTest -Dsurefire.failIfNoSpecifiedTests=false test 2>&1)"; then printf 'Java Secret Provider example round %s failed\n%s\n' "$round" "$output" >&2 exit 1 fi - if [[ "$output" == *"private-example-key"* ]]; then + 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 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..4e68baa5 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/secret/VaultKv2SecretProvider.java @@ -0,0 +1,177 @@ +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 { + 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 = context.httpRequests().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/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..c9e96c91 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java @@ -0,0 +1,100 @@ +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.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(), http); + return context; + } + + @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 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")); + } +} From 047ddd0c364f0cfe65215470e02e0554f5425f7c Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 05:50:10 +0800 Subject: [PATCH 05/29] Reject untrusted Vault HTTP profile before token resolution --- .../secret/VaultKv2SecretProvider.java | 6 +++- .../secret/VaultKv2SecretProviderTest.java | 31 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) 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 index 4e68baa5..23a4e5ca 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/secret/VaultKv2SecretProvider.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/secret/VaultKv2SecretProvider.java @@ -51,6 +51,10 @@ public static Builder builder(String httpProfile, String mount, 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()); @@ -60,7 +64,7 @@ public static Builder builder(String httpProfile, String mount, Map.of("X-Vault-Token", token), null, null, "read configured Vault secret", HttpRequestService.IntentKind.PURPOSE, "resolve runtime credential", HttpRequestService.RetryMode.NONE); - HttpRequestService.Response response = context.httpRequests().execute(context, call); + 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()); 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 index c9e96c91..c2db7900 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java @@ -8,6 +8,7 @@ import io.teaql.core.secret.SecretValue; import io.teaql.runtime.DefaultUserContext; import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; import static org.junit.Assert.*; @@ -27,10 +28,38 @@ private VaultKv2SecretProvider provider() { private DefaultUserContext context(VaultKv2SecretProvider provider, HttpRequestService http) { DefaultUserContext context = new DefaultUserContext(null).withSecretProvider(provider); - context.putAttribute(HttpRequestService.class.getName(), http); + 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) -> { From d80b82872e160eaa4368749c9992924a14fc8751 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 05:57:58 +0800 Subject: [PATCH 06/29] Qualify profile-scoped TLS trust for Vault example --- .../examples/secret/VaultKv2ExampleTest.java | 135 ++++++++++++++++++ examples/secret-provider/verify.sh | 1 + .../teaql/tools/http/HttpServiceProfile.java | 25 ++++ .../tools/http/JdkHttpRequestService.java | 25 +++- .../teaql/tools/http/HttpResilienceTest.java | 22 +++ 5 files changed, 206 insertions(+), 2 deletions(-) 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 index e80b3aa3..c8f82325 100644 --- 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 @@ -15,10 +15,22 @@ 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.concurrent.Executors; import java.util.concurrent.TimeUnit; +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 static org.junit.Assert.*; @@ -42,6 +54,129 @@ private DefaultUserContext context(int port, Duration timeout) { return context; } + private DefaultUserContext tlsContext(int port, byte[] trustedRoot) { + var builder = HttpServiceProfile.builder("vault", URI.create("https://127.0.0.1:" + port + "/")) + .budgets(Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ofSeconds(3)); + if (trustedRoot != null) builder.trustedRootPem(trustedRoot); + 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(builder.build()))); + return context; + } + + 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 { + var directory = Files.createTempDirectory("teaql-vault-tls-"); + var rootPath = directory.resolve("root.pem"); + var rootKeyPath = directory.resolve("root.key"); + var requestPath = directory.resolve("server.csr"); + var serialPath = directory.resolve("root.srl"); + var certificatePath = directory.resolve("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", "-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()); + byte[] rootPem = Files.readAllBytes(rootPath); + Certificate certificate; + Certificate root; + 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); + } + 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); + + var executor = Executors.newSingleThreadExecutor(); + try (SSLServerSocket server = (SSLServerSocket) tls.getServerSocketFactory() + .createServerSocket(0, 2, InetAddress.getLoopbackAddress())) { + server.setSoTimeout(3000); + var served = executor.submit(() -> { + for (int connection = 0; connection < 2; connection++) { + try (SSLSocket socket = (SSLSocket) server.accept()) { + socket.setSoTimeout(2000); + BufferedReader reader = new BufferedReader(new InputStreamReader( + socket.getInputStream(), StandardCharsets.US_ASCII)); + String firstLine = reader.readLine(); + if (connection == 0) { + assertNull("untrusted peer received an HTTP request", firstLine); + continue; + } + 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); + } catch (javax.net.ssl.SSLException expectedUntrusted) { + if (connection != 0) throw expectedUntrusted; + } + } + return null; + }); + SecretFailure untrusted = assertThrows(SecretFailure.class, + () -> tlsContext(server.getLocalPort(), null) + .resolveSecret(REFERENCE, "read configured credential")); + assertEquals(SecretFailure.Code.UNAVAILABLE, untrusted.code()); + SecretValue value = tlsContext(server.getLocalPort(), rootPem) + .resolveSecret(REFERENCE, "read configured credential"); + assertEquals("PRIVATE-VAULT-VALUE", new String(value.exposeBytes(), StandardCharsets.UTF_8)); + assertEquals("vault-kv2:7", value.version()); + served.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + } finally { + Files.deleteIfExists(keyPath); + Files.deleteIfExists(certificatePath); + Files.deleteIfExists(requestPath); + Files.deleteIfExists(serialPath); + Files.deleteIfExists(rootKeyPath); + Files.deleteIfExists(rootPath); + Files.deleteIfExists(directory); + } + } + @Test public void loopbackVaultPathTokenVersionAndTimeout() throws Exception { var successExecutor = Executors.newSingleThreadExecutor(); try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { diff --git a/examples/secret-provider/verify.sh b/examples/secret-provider/verify.sh index f4b7c576..61cc93fd 100644 --- a/examples/secret-provider/verify.sh +++ b/examples/secret-provider/verify.sh @@ -1,5 +1,6 @@ #!/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" for round in 1 2; do 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..1335c65e 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,7 +1,10 @@ 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.EnumSet; import java.util.Objects; @@ -11,6 +14,7 @@ public final class HttpServiceProfile { private final String alias; private final URI baseUri; + private final X509Certificate trustedRoot; private final Duration connectTimeout; private final Duration requestTimeout; private final Duration totalTimeout; @@ -26,6 +30,7 @@ public final class HttpServiceProfile { private HttpServiceProfile(Builder b) { this.alias = b.alias; this.baseUri = b.baseUri; + this.trustedRoot = b.trustedRoot; this.connectTimeout = b.connectTimeout; this.requestTimeout = b.requestTimeout; this.totalTimeout = b.totalTimeout; @@ -42,6 +47,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; } + X509Certificate trustedRoot() { return trustedRoot; } public Duration connectTimeout() { return connectTimeout; } public Duration requestTimeout() { return requestTimeout; } public Duration totalTimeout() { return totalTimeout; } @@ -58,6 +64,7 @@ private HttpServiceProfile(Builder b) { public static final class Builder { private final String alias; private final URI baseUri; + private X509Certificate trustedRoot; private Duration connectTimeout = Duration.ofSeconds(2); private Duration requestTimeout = Duration.ofSeconds(5); private Duration totalTimeout = Duration.ofSeconds(10); @@ -94,6 +101,24 @@ public Builder budgets(Duration connect, Duration request, Duration total) { return this; } public Builder maxResponseBytes(int bytes) { this.maxResponseBytes = bytes; return this; } + /** Use only this profile's trusted root for HTTPS; peer and hostname checks remain enabled. */ + public Builder trustedRootPem(byte[] pem) { + if (!"https".equalsIgnoreCase(baseUri.getScheme()) || pem == null + || pem.length == 0 || pem.length > 64 * 1024) { + throw new IllegalArgumentException("invalid trusted TLS root"); + } + try { + var source = new ByteArrayInputStream(pem.clone()); + var certificate = CertificateFactory.getInstance("X.509").generateCertificate(source); + if (!(certificate instanceof X509Certificate x509)) { + throw new IllegalArgumentException("invalid trusted TLS root"); + } + trustedRoot = x509; + } 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..4dd7b710 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 { @@ -265,11 +269,28 @@ 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.trustedRoot() != null) 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); + store.setCertificateEntry("root", profile.trustedRoot()); + 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); } From c41a5e3538fe94ca122a8db54234bf423f622bf0 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 06:01:14 +0800 Subject: [PATCH 07/29] Capture Vault TLS telemetry and assert canary redaction --- .../examples/secret/VaultKv2ExampleTest.java | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) 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 index c8f82325..504e0b8a 100644 --- 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 @@ -1,10 +1,13 @@ 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.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.VaultKv2SecretProvider; import io.teaql.tools.http.HttpServiceProfile; import io.teaql.tools.http.JdkHttpRequestService; @@ -25,8 +28,13 @@ import java.time.Duration; import java.util.Base64; import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +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; @@ -54,7 +62,7 @@ private DefaultUserContext context(int port, Duration timeout) { return context; } - private DefaultUserContext tlsContext(int port, byte[] trustedRoot) { + private DefaultUserContext tlsContext(int port, byte[] trustedRoot, List captured) { var builder = HttpServiceProfile.builder("vault", URI.create("https://127.0.0.1:" + port + "/")) .budgets(Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ofSeconds(3)); if (trustedRoot != null) builder.trustedRootPem(trustedRoot); @@ -63,7 +71,23 @@ private DefaultUserContext tlsContext(int port, byte[] trustedRoot) { "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); + 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; @@ -79,6 +103,15 @@ private static void openssl(String... arguments) throws Exception { } @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"); @@ -155,18 +188,27 @@ private static void openssl(String... arguments) throws Exception { return null; }); SecretFailure untrusted = assertThrows(SecretFailure.class, - () -> tlsContext(server.getLocalPort(), null) + () -> tlsContext(server.getLocalPort(), null, captured) .resolveSecret(REFERENCE, "read configured credential")); assertEquals(SecretFailure.Code.UNAVAILABLE, untrusted.code()); - SecretValue value = tlsContext(server.getLocalPort(), rootPem) + assertNull(untrusted.getCause()); + SecretValue value = tlsContext(server.getLocalPort(), rootPem, captured) .resolveSecret(REFERENCE, "read configured credential"); assertEquals("PRIVATE-VAULT-VALUE", new String(value.exposeBytes(), StandardCharsets.UTF_8)); assertEquals("vault-kv2:7", value.version()); served.get(5, TimeUnit.SECONDS); + assertTrue(captured.toString().contains("start:Operation")); + assertTrue(captured.toString().contains("success:")); + assertTrue(captured.toString().contains("failure:")); + assertEquals(2, logs.size()); + String diagnostics = captured + "\n" + logs; + assertFalse(diagnostics.contains("PRIVATE-VAULT-TOKEN")); + assertFalse(diagnostics.contains("PRIVATE-VAULT-VALUE")); } finally { executor.shutdownNow(); } } finally { + logger.removeHandler(handler); Files.deleteIfExists(keyPath); Files.deleteIfExists(certificatePath); Files.deleteIfExists(requestPath); From 554d2445237640b61901a4d843cdaed45795b4ec Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 06:08:40 +0800 Subject: [PATCH 08/29] test: verify Secret Provider against live Vault KV v2 TLS --- .../examples/secret/VaultKv2ExampleTest.java | 64 +++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) 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 index 504e0b8a..c5b3a9b8 100644 --- 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 @@ -3,11 +3,13 @@ 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; @@ -40,6 +42,7 @@ 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 { @@ -63,14 +66,21 @@ private DefaultUserContext context(int port, Duration timeout) { } private DefaultUserContext tlsContext(int port, byte[] trustedRoot, List captured) { - var builder = HttpServiceProfile.builder("vault", URI.create("https://127.0.0.1:" + port + "/")) + 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(2), Duration.ofSeconds(2), Duration.ofSeconds(3)); if (trustedRoot != null) builder.trustedRootPem(trustedRoot); - var provider = VaultKv2SecretProvider.builder("vault", "secret", - (caller, reference) -> new SecretValue( - "PRIVATE-VAULT-TOKEN".getBytes(StandardCharsets.UTF_8), null, null), + var provider = VaultKv2SecretProvider.builder("vault", "secret", bootstrap, new SecretReference("bootstrap", "vault_token")) - .bind(REFERENCE, "payment/api", "value").build(); + .bind(REFERENCE, path, "value").build(); RuntimeTelemetry telemetry = operation -> { captured.add("start:" + operation); return new RuntimeTelemetry.Scope() { @@ -93,6 +103,50 @@ private DefaultUserContext tlsContext(int port, byte[] trustedRoot, List 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"; From 53c8ebc6229d9590708a69127893079af0ff0255 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 06:19:24 +0800 Subject: [PATCH 09/29] fix: bind file secrets to the original secure directory --- .../runtime/secret/BoundSecretProvider.java | 88 ++++++++++++------- .../secret/BoundSecretProviderTest.java | 33 +++++++ 2 files changed, 88 insertions(+), 33 deletions(-) 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 index 839327d2..ec02e919 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/secret/BoundSecretProvider.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/secret/BoundSecretProvider.java @@ -9,10 +9,14 @@ 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; @@ -25,12 +29,14 @@ 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, Map bindings, + private BoundSecretProvider(Path root, Object rootFileKey, Map bindings, Function environment) { this.root = root; + this.rootFileKey = rootFileKey; this.bindings = Map.copyOf(bindings); this.environment = environment; } @@ -56,60 +62,67 @@ static Builder testBuilder(Path fileRoot, Function environment) if (value == null) throw new SecretFailure(SecretFailure.Code.MISSING); bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8); } else { - bytes = readFile(root, binding.file()); + bytes = readFile(root, rootFileKey, binding.file()); } return new SecretValue(bytes, binding.version(), binding.expiresAt()); } - private static byte[] readFile(Path root, Path relative) { - Path candidate = root; - try { - for (Path component : relative) { - candidate = candidate.resolve(component); - if (Files.isSymbolicLink(candidate)) throw new SecretFailure(SecretFailure.Code.DENIED); - if (!Files.exists(candidate, LinkOption.NOFOLLOW_LINKS)) { - throw new SecretFailure(SecretFailure.Code.MISSING); - } - } - Path actual = candidate.toRealPath(LinkOption.NOFOLLOW_LINKS); - if (!actual.startsWith(root) || !Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS)) { + 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); } - if (Files.size(candidate) > SecretValue.MAX_BYTES) throw new SecretFailure(SecretFailure.Code.DENIED); - try { - Set mode = Files.getPosixFilePermissions(candidate, - LinkOption.NOFOLLOW_LINKS); - if (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)) { - throw new SecretFailure(SecretFailure.Code.DENIED); - } - } catch (UnsupportedOperationException ignored) { - // No portable owner-only permission proof: fail closed. + 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); } - try (SeekableByteChannel channel = Files.newByteChannel(candidate, + 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) { + 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); } - byte[] bytes = stream.readNBytes(SecretValue.MAX_BYTES + 1); - if (bytes.length > SecretValue.MAX_BYTES) 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<>(); @@ -117,6 +130,7 @@ 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, @@ -130,6 +144,14 @@ private Builder(Path fileRoot, Function environment) { 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) { @@ -165,7 +187,7 @@ public Builder file(SecretReference reference, Path relative, } public BoundSecretProvider build() { - return new BoundSecretProvider(root, bindings, environment); + return new BoundSecretProvider(root, rootFileKey, bindings, environment); } } } 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 index 19bfa971..862971c7 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/BoundSecretProviderTest.java @@ -137,6 +137,8 @@ public class BoundSecretProviderTest { 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)); } @@ -153,6 +155,37 @@ public class BoundSecretProviderTest { () -> 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()); } From ae6f92e8a72503ed8be8421a381cbe2a8a695a5a Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 08:39:32 +0800 Subject: [PATCH 10/29] Add Vault exported telemetry probe to secret example --- examples/secret-provider/pom.xml | 1 + .../secret/SecretOpenObserveProbeTest.java | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretOpenObserveProbeTest.java diff --git a/examples/secret-provider/pom.xml b/examples/secret-provider/pom.xml index 11b0a294..e9480136 100644 --- a/examples/secret-provider/pom.xml +++ b/examples/secret-provider/pom.xml @@ -5,6 +5,7 @@ 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..7266d0da --- /dev/null +++ b/examples/secret-provider/src/test/java/io/teaql/examples/secret/SecretOpenObserveProbeTest.java @@ -0,0 +1,75 @@ +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.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.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); + 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")); + byte[] body = ("{\"data\":{\"data\":{\"value\":\"" + VALUE + + "\"},\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"\"}}}") + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(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 profile = HttpServiceProfile.builder("vault", + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/")) + .build(); + context.putAttribute(HttpRequestService.class.getName(), + new JdkHttpRequestService(List.of(profile))); + assertEquals(VALUE, new String(context.resolveSecret(reference, "verify Vault adapter") + .exposeBytes(), StandardCharsets.UTF_8)); + assertNotNull(carrier.get()); + assertTrue(carrier.get().startsWith("00-") && carrier.get().length() == 55); + telemetry.flush(); + System.out.println("SECRET_OTLP_PROBE_PASS traceparent=" + carrier.get()); + } finally { + server.stop(0); + telemetry.shutdown(); + } + } +} From cd014e2a7af5aaf98888e9a375fd6052caf816c6 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 08:43:55 +0800 Subject: [PATCH 11/29] Probe denied Vault read through exported telemetry --- .../secret/SecretOpenObserveProbeTest.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) 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 index 7266d0da..93cbbcdf 100644 --- 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 @@ -6,6 +6,7 @@ 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; @@ -33,6 +34,9 @@ public class SecretOpenObserveProbeTest { 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", outcome.equals("success") || outcome.equals("denied")); + boolean denied = outcome.equals("denied"); var telemetry = new StandardOpenTelemetryFactory().create(serviceName, endpoint); var carrier = new AtomicReference(); HttpServer server = HttpServer.create(new InetSocketAddress( @@ -40,10 +44,12 @@ public class SecretOpenObserveProbeTest { server.createContext("/v1/secret/data/payment/api", exchange -> { assertEquals(TOKEN, exchange.getRequestHeaders().getFirst("X-Vault-Token")); carrier.set(exchange.getRequestHeaders().getFirst("traceparent")); - byte[] body = ("{\"data\":{\"data\":{\"value\":\"" + VALUE - + "\"},\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"\"}}}") + byte[] body = (denied + ? "{\"errors\":[\"" + VALUE + "\"]}" + : "{\"data\":{\"data\":{\"value\":\"" + VALUE + + "\"},\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"\"}}}") .getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, body.length); + exchange.sendResponseHeaders(denied ? 403 : 200, body.length); try (var output = exchange.getResponseBody()) { output.write(body); } }); server.start(); @@ -61,12 +67,18 @@ public class SecretOpenObserveProbeTest { .build(); context.putAttribute(HttpRequestService.class.getName(), new JdkHttpRequestService(List.of(profile))); - assertEquals(VALUE, new String(context.resolveSecret(reference, "verify Vault adapter") - .exposeBytes(), StandardCharsets.UTF_8)); + if (denied) { + assertEquals(SecretFailure.Code.DENIED, assertThrows(SecretFailure.class, + () -> context.resolveSecret(reference, "verify Vault adapter")).code()); + } else { + assertEquals(VALUE, new String(context.resolveSecret(reference, "verify Vault adapter") + .exposeBytes(), StandardCharsets.UTF_8)); + } assertNotNull(carrier.get()); assertTrue(carrier.get().startsWith("00-") && carrier.get().length() == 55); telemetry.flush(); - System.out.println("SECRET_OTLP_PROBE_PASS traceparent=" + carrier.get()); + System.out.println("SECRET_OTLP_PROBE_PASS outcome=" + outcome + + " traceparent=" + carrier.get()); } finally { server.stop(0); telemetry.shutdown(); From 2b1b546d46aff5b8815c79b4f7982486a9d85fba Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 09:15:57 +0800 Subject: [PATCH 12/29] Cover Vault missing malformed and expired telemetry probes --- .../secret/SecretOpenObserveProbeTest.java | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) 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 index 93cbbcdf..d15cf002 100644 --- 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 @@ -35,8 +35,8 @@ public class SecretOpenObserveProbeTest { String serviceName = System.getenv("TEAQL_TELEMETRY_SERVICE_NAME"); assertNotNull(serviceName); String outcome = System.getenv().getOrDefault("TEAQL_SECRET_PROBE_OUTCOME", "success"); - assertTrue("unsupported probe outcome", outcome.equals("success") || outcome.equals("denied")); - boolean denied = outcome.equals("denied"); + assertTrue("unsupported probe outcome", List.of("success", "denied", "missing", + "malformed", "expired").contains(outcome)); var telemetry = new StandardOpenTelemetryFactory().create(serviceName, endpoint); var carrier = new AtomicReference(); HttpServer server = HttpServer.create(new InetSocketAddress( @@ -44,12 +44,16 @@ public class SecretOpenObserveProbeTest { server.createContext("/v1/secret/data/payment/api", exchange -> { assertEquals(TOKEN, exchange.getRequestHeaders().getFirst("X-Vault-Token")); carrier.set(exchange.getRequestHeaders().getFirst("traceparent")); - byte[] body = (denied - ? "{\"errors\":[\"" + VALUE + "\"]}" - : "{\"data\":{\"data\":{\"value\":\"" + VALUE - + "\"},\"metadata\":{\"version\":7,\"destroyed\":false,\"deletion_time\":\"\"}}}") - .getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(denied ? 403 : 200, body.length); + 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(); @@ -67,12 +71,18 @@ public class SecretOpenObserveProbeTest { .build(); context.putAttribute(HttpRequestService.class.getName(), new JdkHttpRequestService(List.of(profile))); - if (denied) { - assertEquals(SecretFailure.Code.DENIED, assertThrows(SecretFailure.class, - () -> context.resolveSecret(reference, "verify Vault adapter")).code()); - } else { + 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); From 8c9e8fae9957ca7db2b6f62766d0dd136c92287b Mon Sep 17 00:00:00 2001 From: Philip Z Date: Fri, 16 Oct 2026 09:20:06 +0800 Subject: [PATCH 13/29] Exercise stalled Vault response in telemetry probe --- .../secret/SecretOpenObserveProbeTest.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) 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 index d15cf002..68f93105 100644 --- 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 @@ -19,6 +19,7 @@ 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; @@ -36,7 +37,7 @@ public class SecretOpenObserveProbeTest { assertNotNull(serviceName); String outcome = System.getenv().getOrDefault("TEAQL_SECRET_PROBE_OUTCOME", "success"); assertTrue("unsupported probe outcome", List.of("success", "denied", "missing", - "malformed", "expired").contains(outcome)); + "malformed", "expired", "timeout").contains(outcome)); var telemetry = new StandardOpenTelemetryFactory().create(serviceName, endpoint); var carrier = new AtomicReference(); HttpServer server = HttpServer.create(new InetSocketAddress( @@ -44,6 +45,16 @@ public class SecretOpenObserveProbeTest { 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 + "\","; @@ -66,9 +77,13 @@ public class SecretOpenObserveProbeTest { new SecretReference("bootstrap", "vault_token")) .bind(reference, "payment/api", "value").build(); var context = new DefaultUserContext(runtime).withSecretProvider(provider); - var profile = HttpServiceProfile.builder("vault", - URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/")) - .build(); + 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")) { From fa6a25febd89e8457cdb92b04ee4abae5d69017d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 12:30:26 +0800 Subject: [PATCH 14/29] Make Vault TLS example resilient to aborted handshakes --- .../examples/secret/VaultKv2ExampleTest.java | 95 +++++++++++++------ 1 file changed, 64 insertions(+), 31 deletions(-) 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 index c5b3a9b8..a5dc88c1 100644 --- 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 @@ -34,6 +34,8 @@ import java.util.concurrent.CopyOnWriteArrayList; 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; @@ -76,7 +78,7 @@ private DefaultUserContext configuredTlsContext(URI endpoint, byte[] trustedRoot SecretProvider bootstrap, String path, List captured) { var builder = HttpServiceProfile.builder("vault", endpoint) - .budgets(Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ofSeconds(3)); + .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")) @@ -207,36 +209,59 @@ private static void openssl(String... arguments) throws Exception { SSLContext tls = SSLContext.getInstance("TLS"); tls.init(managers.getKeyManagers(), null, null); - var executor = Executors.newSingleThreadExecutor(); + var executor = Executors.newCachedThreadPool(); + AtomicInteger receivedRequests = new AtomicInteger(); + List fixtureEvents = new CopyOnWriteArrayList<>(); try (SSLServerSocket server = (SSLServerSocket) tls.getServerSocketFactory() .createServerSocket(0, 2, InetAddress.getLoopbackAddress())) { - server.setSoTimeout(3000); - var served = executor.submit(() -> { - for (int connection = 0; connection < 2; connection++) { - try (SSLSocket socket = (SSLSocket) server.accept()) { - socket.setSoTimeout(2000); - BufferedReader reader = new BufferedReader(new InputStreamReader( - socket.getInputStream(), StandardCharsets.US_ASCII)); - String firstLine = reader.readLine(); - if (connection == 0) { - assertNull("untrusted peer received an HTTP request", firstLine); - continue; - } - 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"); + server.setSoTimeout(250); + CompletableFuture served = new CompletableFuture<>(); + var acceptor = executor.submit(() -> { + while (!served.isDone()) { + try { + SSLSocket socket = (SSLSocket) server.accept(); + 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"); + served.complete(null); + } 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. + } catch (Throwable error) { + fixtureEvents.add("fixture-error:" + error.getClass().getSimpleName()); + served.completeExceptionally(error); } - } - 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); - } catch (javax.net.ssl.SSLException expectedUntrusted) { - if (connection != 0) throw expectedUntrusted; + }); + } catch (java.net.SocketTimeoutException awaitingConnection) { + // Recheck whether the trusted request has finished. + } catch (Throwable error) { + served.completeExceptionally(error); } } return null; @@ -246,11 +271,19 @@ private static void openssl(String... arguments) throws Exception { .resolveSecret(REFERENCE, "read configured credential")); assertEquals(SecretFailure.Code.UNAVAILABLE, untrusted.code()); assertNull(untrusted.getCause()); - SecretValue value = tlsContext(server.getLocalPort(), rootPem, captured) - .resolveSecret(REFERENCE, "read configured credential"); + assertEquals("untrusted peer sent an HTTP request", 0, receivedRequests.get()); + SecretValue value; + try { + value = tlsContext(server.getLocalPort(), rootPem, captured) + .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()); - served.get(5, TimeUnit.SECONDS); + served.get(20, TimeUnit.SECONDS); + acceptor.get(20, TimeUnit.SECONDS); + assertEquals("trusted request must be handled once", 1, receivedRequests.get()); assertTrue(captured.toString().contains("start:Operation")); assertTrue(captured.toString().contains("success:")); assertTrue(captured.toString().contains("failure:")); From 8b385e1f2ecde15f2bb90bf701cdd429ae22ab3c Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 12:52:25 +0800 Subject: [PATCH 15/29] test: verify Vault bootstrap token rotation fails closed --- .../secret/VaultKv2SecretProviderTest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 index c2db7900..539f85df 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java @@ -81,6 +81,48 @@ private DefaultUserContext context(VaultKv2SecretProvider provider, HttpRequestS () -> 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 failuresAreSafeAndDistinct() { for (int status : new int[] {403, 404, 500}) { HttpRequestService http = (context, call) -> new HttpRequestService.Response( From 08a1f92bbab17fbc451d192a64b7c01af8a1330d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 12:58:17 +0800 Subject: [PATCH 16/29] test: reject removed Vault TLS trust in one Context --- .../examples/secret/VaultKv2ExampleTest.java | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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 index a5dc88c1..55335082 100644 --- 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 @@ -211,6 +211,7 @@ private static void openssl(String... arguments) throws Exception { 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())) { @@ -220,6 +221,7 @@ private static void openssl(String... arguments) throws Exception { while (!served.isDone()) { try { SSLSocket socket = (SSLSocket) server.accept(); + int connection = acceptedConnections.incrementAndGet(); fixtureEvents.add("accepted"); executor.execute(() -> { try (socket) { @@ -247,12 +249,12 @@ private static void openssl(String... arguments) throws Exception { .getBytes(StandardCharsets.US_ASCII)); socket.getOutputStream().write(BODY); fixtureEvents.add("responded"); - served.complete(null); } 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 == 3) served.complete(null); } catch (Throwable error) { fixtureEvents.add("fixture-error:" + error.getClass().getSimpleName()); served.completeExceptionally(error); @@ -266,28 +268,43 @@ private static void openssl(String... arguments) throws Exception { } return null; }); + DefaultUserContext context = tlsContext(server.getLocalPort(), null, captured); SecretFailure untrusted = assertThrows(SecretFailure.class, - () -> tlsContext(server.getLocalPort(), null, captured) - .resolveSecret(REFERENCE, "read configured credential")); + () -> 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 = tlsContext(server.getLocalPort(), rootPem, captured) - .resolveSecret(REFERENCE, "read configured credential"); + 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 removedTrustProfile = HttpServiceProfile.builder("vault", + URI.create("https://127.0.0.1:" + server.getLocalPort() + "/")) + .budgets(Duration.ofSeconds(5), Duration.ofSeconds(5), Duration.ofSeconds(8)) + .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 request must be handled once", 1, receivedRequests.get()); assertTrue(captured.toString().contains("start:Operation")); assertTrue(captured.toString().contains("success:")); assertTrue(captured.toString().contains("failure:")); - assertEquals(2, logs.size()); + assertEquals(3, logs.size()); String diagnostics = captured + "\n" + logs; assertFalse(diagnostics.contains("PRIVATE-VAULT-TOKEN")); assertFalse(diagnostics.contains("PRIVATE-VAULT-VALUE")); From 7671849e72a4a66b42648725b3f42c7f6bde891a Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 13:04:16 +0800 Subject: [PATCH 17/29] feat: support explicit private CA overlap for HTTP profiles --- examples/secret-provider/README.md | 9 ++ .../examples/secret/VaultKv2ExampleTest.java | 112 +++++++++++++++++- .../teaql/tools/http/HttpServiceProfile.java | 41 +++++-- .../tools/http/JdkHttpRequestService.java | 6 +- 4 files changed, 151 insertions(+), 17 deletions(-) diff --git a/examples/secret-provider/README.md b/examples/secret-provider/README.md index 853d5326..296700ec 100644 --- a/examples/secret-provider/README.md +++ b/examples/secret-provider/README.md @@ -10,3 +10,12 @@ 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 and production certificate rollout require separate coordination. 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 index 55335082..6d90b0d0 100644 --- 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 @@ -171,30 +171,64 @@ private static void openssl(String... arguments) throws Exception { 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", ""); @@ -208,6 +242,13 @@ private static void openssl(String... arguments) throws Exception { 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(); @@ -254,7 +295,7 @@ private static void openssl(String... arguments) throws Exception { 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 == 3) served.complete(null); + if (connection == 4) served.complete(null); } catch (Throwable error) { fixtureEvents.add("fixture-error:" + error.getClass().getSimpleName()); served.completeExceptionally(error); @@ -288,9 +329,18 @@ private static void openssl(String... arguments) throws Exception { } 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))); @@ -300,14 +350,66 @@ private static void openssl(String... arguments) throws Exception { assertNull(trustRemoved.getCause()); served.get(20, TimeUnit.SECONDS); acceptor.get(20, TimeUnit.SECONDS); - assertEquals("trusted request must be handled once", 1, receivedRequests.get()); + 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(3, logs.size()); + 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")); } finally { executor.shutdownNow(); } @@ -315,10 +417,14 @@ private static void openssl(String... arguments) throws Exception { 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); } } 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 1335c65e..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 @@ -6,7 +6,9 @@ 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; @@ -14,7 +16,7 @@ public final class HttpServiceProfile { private final String alias; private final URI baseUri; - private final X509Certificate trustedRoot; + private final List trustedRoots; private final Duration connectTimeout; private final Duration requestTimeout; private final Duration totalTimeout; @@ -30,7 +32,7 @@ public final class HttpServiceProfile { private HttpServiceProfile(Builder b) { this.alias = b.alias; this.baseUri = b.baseUri; - this.trustedRoot = b.trustedRoot; + this.trustedRoots = b.trustedRoots; this.connectTimeout = b.connectTimeout; this.requestTimeout = b.requestTimeout; this.totalTimeout = b.totalTimeout; @@ -47,7 +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; } - X509Certificate trustedRoot() { return trustedRoot; } + List trustedRoots() { return trustedRoots; } public Duration connectTimeout() { return connectTimeout; } public Duration requestTimeout() { return requestTimeout; } public Duration totalTimeout() { return totalTimeout; } @@ -64,7 +66,7 @@ private HttpServiceProfile(Builder b) { public static final class Builder { private final String alias; private final URI baseUri; - private X509Certificate trustedRoot; + private List trustedRoots = List.of(); private Duration connectTimeout = Duration.ofSeconds(2); private Duration requestTimeout = Duration.ofSeconds(5); private Duration totalTimeout = Duration.ofSeconds(10); @@ -101,19 +103,34 @@ public Builder budgets(Duration connect, Duration request, Duration total) { return this; } public Builder maxResponseBytes(int bytes) { this.maxResponseBytes = bytes; return this; } - /** Use only this profile's trusted root for HTTPS; peer and hostname checks remain enabled. */ + /** Replace this profile's custom trust set with exactly one PEM root. */ public Builder trustedRootPem(byte[] pem) { - if (!"https".equalsIgnoreCase(baseUri.getScheme()) || pem == null - || pem.length == 0 || pem.length > 64 * 1024) { + 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 source = new ByteArrayInputStream(pem.clone()); - var certificate = CertificateFactory.getInstance("X.509").generateCertificate(source); - if (!(certificate instanceof X509Certificate x509)) { - throw new IllegalArgumentException("invalid trusted TLS root"); + 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); } - trustedRoot = x509; + trustedRoots = List.copyOf(parsed); } catch (java.security.cert.CertificateException failure) { throw new IllegalArgumentException("invalid trusted TLS root"); } 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 4dd7b710..8e82b94f 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 @@ -272,7 +272,7 @@ private static final class State { HttpClient.Builder builder = HttpClient.newBuilder() .connectTimeout(profile.connectTimeout()) .followRedirects(HttpClient.Redirect.NEVER); - if (profile.trustedRoot() != null) builder.sslContext(trustedRootContext(profile)); + if (!profile.trustedRoots().isEmpty()) builder.sslContext(trustedRootContext(profile)); this.client = builder.build(); this.tokens = profile.ratePerSecond(); } @@ -280,7 +280,9 @@ private static SSLContext trustedRootContext(HttpServiceProfile profile) { try { KeyStore store = KeyStore.getInstance("PKCS12"); store.load(null, null); - store.setCertificateEntry("root", profile.trustedRoot()); + 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); From f797707cb168d1cf76edab069f49852a8fc0dfa6 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 13:37:57 +0800 Subject: [PATCH 18/29] Verify in-flight TLS trust replacement semantics --- examples/secret-provider/README.md | 7 +- .../examples/secret/VaultKv2ExampleTest.java | 80 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/examples/secret-provider/README.md b/examples/secret-provider/README.md index 296700ec..4bca18ee 100644 --- a/examples/secret-provider/README.md +++ b/examples/secret-provider/README.md @@ -18,4 +18,9 @@ roots rather than expanding process-wide trust. A later profile with only 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 and production certificate rollout require separate coordination. +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. 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 index 6d90b0d0..416674c7 100644 --- 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 @@ -410,6 +410,86 @@ private static void openssl(String... arguments) throws Exception { 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")); } finally { executor.shutdownNow(); } From 153e3dc84edb30fe8a7397a4bec735ff4922b10c Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 13:56:55 +0800 Subject: [PATCH 19/29] Verify mixed-CA multi-context rollover --- examples/secret-provider/README.md | 5 + .../examples/secret/VaultKv2ExampleTest.java | 121 ++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/examples/secret-provider/README.md b/examples/secret-provider/README.md index 4bca18ee..bd08624b 100644 --- a/examples/secret-provider/README.md +++ b/examples/secret-provider/README.md @@ -24,3 +24,8 @@ 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. 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 index 416674c7..f5203ac4 100644 --- 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 @@ -490,6 +490,88 @@ private static void openssl(String... arguments) throws Exception { 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")); + } + } } finally { executor.shutdownNow(); } @@ -509,6 +591,45 @@ private static void openssl(String... arguments) throws Exception { } } + 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; + } + } + } + } + } + @Test public void loopbackVaultPathTokenVersionAndTimeout() throws Exception { var successExecutor = Executors.newSingleThreadExecutor(); try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { From 2bc4b6b60aff2dc9bd8ef2afaaffaa1035f716ff Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 14:13:36 +0800 Subject: [PATCH 20/29] test: bound stalled Vault TLS read across trust replacement --- examples/secret-provider/README.md | 5 ++ .../examples/secret/VaultKv2ExampleTest.java | 64 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/examples/secret-provider/README.md b/examples/secret-provider/README.md index bd08624b..3f6efb11 100644 --- a/examples/secret-provider/README.md +++ b/examples/secret-provider/README.md @@ -29,3 +29,8 @@ 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 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/src/test/java/io/teaql/examples/secret/VaultKv2ExampleTest.java b/examples/secret-provider/src/test/java/io/teaql/examples/secret/VaultKv2ExampleTest.java index f5203ac4..76e209af 100644 --- 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 @@ -572,6 +572,70 @@ private static void openssl(String... arguments) throws Exception { assertFalse(events.toString().contains("PRIVATE-VAULT-VALUE")); } } + + // 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(); } From 0a1efd64c55b69beb105598f57a8aa33cc3a9f45 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 14:34:00 +0800 Subject: [PATCH 21/29] test: qualify Vault TLS rollover across client processes --- examples/secret-provider/README.md | 7 + .../examples/secret/VaultKv2ExampleTest.java | 125 ++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/examples/secret-provider/README.md b/examples/secret-provider/README.md index 3f6efb11..9696e4f8 100644 --- a/examples/secret-provider/README.md +++ b/examples/secret-provider/README.md @@ -29,6 +29,13 @@ 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 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 index 76e209af..dc3c3385 100644 --- 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 @@ -573,6 +573,70 @@ private static void openssl(String... arguments) throws Exception { } } + // 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() @@ -694,6 +758,67 @@ private static void serveRolloverTls(SSLServerSocket server, int successfulCalls } } + 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())) { From ca56321b80e340f44c3d75bca5bfbf851e4329e1 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 15:08:37 +0800 Subject: [PATCH 22/29] Exercise secret clock and shared custom SPI in native example --- .../secret/SecretProviderExampleTest.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) 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 index 8e640bb4..9a61db2a 100644 --- 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 @@ -1,14 +1,22 @@ 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.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; @@ -44,4 +52,58 @@ public class SecretProviderExampleTest { assertThrows(SecretFailure.class, () -> context.resolveSecret(reference, " ")).code()); } + + @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"); + var executor = Executors.newFixedThreadPool(2); + try { + var firstCall = executor.submit(() -> new String( + first.resolveSecret(reference, "first custom context").exposeBytes(), + StandardCharsets.UTF_8)); + var secondCall = executor.submit(() -> new String( + second.resolveSecret(reference, "second custom context").exposeBytes(), + StandardCharsets.UTF_8)); + 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")); + } } From 2d1222562771c9230dfb7333dfd6b30c7b7bf48d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 15:43:34 +0800 Subject: [PATCH 23/29] Probe independent mutation ledgers with shared secret provider --- .../secret/SecretProviderExampleTest.java | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) 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 index 9a61db2a..b6ba86c7 100644 --- 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 @@ -5,6 +5,8 @@ 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; @@ -25,6 +27,19 @@ 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")); @@ -90,14 +105,24 @@ public class SecretProviderExampleTest { 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(() -> new String( - first.resolveSecret(reference, "first custom context").exposeBytes(), - StandardCharsets.UTF_8)); - var secondCall = executor.submit(() -> new String( - second.resolveSecret(reference, "second custom context").exposeBytes(), - StandardCharsets.UTF_8)); + 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 { @@ -105,5 +130,13 @@ public class SecretProviderExampleTest { } 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")); } } From 76b3f84678ba87c2e52f030e634c41d049091c0d Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 17 Oct 2026 16:01:44 +0800 Subject: [PATCH 24/29] Test shared Vault provider request isolation across contexts --- .../examples/secret/VaultKv2ExampleTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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 index dc3c3385..153462e2 100644 --- 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 @@ -31,7 +31,9 @@ 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; @@ -67,6 +69,63 @@ private DefaultUserContext context(int port, Duration timeout) { 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); From 6b212824d3429af84d787afe899db77f6884a9cd Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 05:40:45 +0800 Subject: [PATCH 25/29] Integrate isolated Secret Provider into current main gates --- examples/verify-runtime-examples.sh | 3 ++- scripts/verify-examples.sh | 3 ++- .../java/io/teaql/core/http/HttpRequestService.java | 4 ++++ .../io/teaql/tools/http/JdkHttpRequestService.java | 11 +++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) 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/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/src/main/java/io/teaql/core/http/HttpRequestService.java b/teaql-core/src/main/java/io/teaql/core/http/HttpRequestService.java index 49de2b04..0f9603d2 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,6 +15,10 @@ /** 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 } 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 8e82b94f..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 @@ -52,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); From 01f7b799d9e3dee7998cc0e2be8bb29d875a6870 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 06:53:38 +0800 Subject: [PATCH 26/29] Prevent reflective JSON from exposing SecretValue bytes Covers SEC-07 in teaql-java#217 with a Gson classpath regression; Jackson remains explicitly denied. --- teaql-core/pom.xml | 6 +++++ .../io/teaql/core/secret/SecretValue.java | 8 +++--- .../secret/SecretValueSerializationTest.java | 25 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 teaql-core/src/test/java/io/teaql/core/secret/SecretValueSerializationTest.java 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/secret/SecretValue.java b/teaql-core/src/main/java/io/teaql/core/secret/SecretValue.java index 7a6eb756..3b7e309e 100644 --- a/teaql-core/src/main/java/io/teaql/core/secret/SecretValue.java +++ b/teaql-core/src/main/java/io/teaql/core/secret/SecretValue.java @@ -11,9 +11,11 @@ @JsonSerialize(using = SecretValue.BlockedSerializer.class) public final class SecretValue { public static final int MAX_BYTES = 16 * 1024; - private final byte[] bytes; - private final String version; - private final Instant expiresAt; + // 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) { 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()); + } +} From 1cf247122ec2978a93c8910678a104b8c35a6118 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 12:26:20 +0800 Subject: [PATCH 27/29] test: prove shared Vault provider request isolation --- .../secret/VaultKv2SecretProviderTest.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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 index 539f85df..6960bb42 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/secret/VaultKv2SecretProviderTest.java @@ -8,6 +8,12 @@ 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; @@ -123,6 +129,67 @@ private DefaultUserContext context(VaultKv2SecretProvider provider, HttpRequestS 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( From a32ddaa3a154610dc59a0e7456d5aeee6df5e6ff Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 18 Oct 2026 16:33:05 +0800 Subject: [PATCH 28/29] Prevent HTTP credential serialization in Java --- .../teaql/core/http/HttpRequestService.java | 122 ++++++++++++++++-- .../core/http/HttpCallSerializationTest.java | 32 +++++ 2 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 teaql-core/src/test/java/io/teaql/core/http/HttpCallSerializationTest.java 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 0f9603d2..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 @@ -23,13 +23,29 @@ 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 @@ -40,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. */ @@ -62,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, @@ -132,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() @@ -159,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/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)); + } +} From a847dac37d351e451ecf4821cc8a42784f839b84 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Thu, 22 Oct 2026 12:49:50 +0800 Subject: [PATCH 29/29] test: preserve pending graph state across secret rotation (#267) [skip ci] --- .../secret/SecretProviderExampleTest.java | 15 ++++++++++++ examples/secret-provider/verify.sh | 23 ++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) 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 index b6ba86c7..6c05a890 100644 --- 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 @@ -53,6 +53,14 @@ private void updateName(String replacement) { 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)); @@ -62,10 +70,16 @@ private void updateName(String replacement) { 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 { @@ -138,5 +152,6 @@ private void updateName(String replacement) { 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/verify.sh b/examples/secret-provider/verify.sh index 61cc93fd..a227f483 100644 --- a/examples/secret-provider/verify.sh +++ b/examples/secret-provider/verify.sh @@ -3,15 +3,32 @@ 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 - if ! output="$(mvn -B -q -pl examples/secret-provider -am \ - -Dtest=SecretProviderExampleTest,VaultKv2ExampleTest -Dsurefire.failIfNoSpecifiedTests=false test 2>&1)"; then + 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 1 + 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