From 41e53e2c1ab476d113ea853f94c907c34b3b57ed Mon Sep 17 00:00:00 2001 From: Eugene Yakhnenko Date: Thu, 20 Aug 2026 08:07:03 -0700 Subject: [PATCH 1/4] feat(sdk): cache ListKeyAccessServers response during decryption Resolves #390 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eugene Yakhnenko --- .../platform/sdk/KASAllowlistCache.java | 58 ++++++++++++ .../java/io/opentdf/platform/sdk/SDK.java | 37 ++++++++ .../platform/sdk/KASAllowlistCacheTest.java | 89 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/KASAllowlistCacheTest.java diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java new file mode 100644 index 00000000..de8d58ff --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java @@ -0,0 +1,58 @@ +package io.opentdf.platform.sdk; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +class KASAllowlistCache { + private static final Logger log = LoggerFactory.getLogger(KASAllowlistCache.class); + Map cache; + + public KASAllowlistCache() { + this.cache = new HashMap<>(); + } + + public void clear() { + this.cache = new HashMap<>(); + } + + public Set get(String platformURL) { + log.debug("retrieving allowlist for platformURL = [{}]", platformURL); + TimeStampedAllowList cachedValue = cache.get(platformURL); + if (cachedValue == null) { + log.debug("didn't find allowlist for platformURL = [{}]", platformURL); + return null; + } + + LocalDateTime fiveMinAgo = LocalDateTime.now().minus(5, ChronoUnit.MINUTES); + if (fiveMinAgo.isAfter(cachedValue.timestamp)) { + log.debug("cached allowlist is too old timestamp = [{}] for platformURL = [{}]", + cachedValue.timestamp, platformURL); + cache.remove(platformURL); + return null; + } + + log.debug("successfully returned allowlist for platformURL = [{}]", platformURL); + return cachedValue.allowlist; + } + + public void store(String platformURL, Set allowlist) { + log.debug("storing allowlist into the cache for platformURL = [{}]", platformURL); + cache.put(platformURL, new TimeStampedAllowList(allowlist, LocalDateTime.now())); + } +} + +class TimeStampedAllowList { + Set allowlist; + LocalDateTime timestamp; + + public TimeStampedAllowList(Set allowlist, LocalDateTime timestamp) { + this.allowlist = allowlist; + this.timestamp = timestamp; + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java index 16c7a35a..f29d93b4 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -18,6 +18,8 @@ import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse; import io.opentdf.platform.policy.attributes.ListAttributesRequest; import io.opentdf.platform.policy.kasregistry.KeyAccessServerRegistryServiceClientInterface; +import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersRequest; +import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersResponse; import io.opentdf.platform.policy.namespaces.NamespaceServiceClientInterface; import io.opentdf.platform.policy.resourcemapping.ResourceMappingServiceClientInterface; import io.opentdf.platform.policy.subjectmapping.SubjectMappingServiceClientInterface; @@ -30,9 +32,11 @@ import java.nio.channels.SeekableByteChannel; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -57,6 +61,7 @@ public class SDK implements AutoCloseable { private final String platformUrl; private final ProtocolClient platformServicesClient; private final SrtSigner srtSigner; + private final KASAllowlistCache kasAllowlistCache = new KASAllowlistCache(); /** * Closes the SDK, including its associated services. @@ -136,10 +141,42 @@ public Optional getBaseKey() { } public TDF.Reader loadTDF(SeekableByteChannel channel, Config.TDFReaderConfig config) throws SDKException, IOException { + resolveKasAllowlist(config); var tdf = new TDF(services); return tdf.loadTDF(channel, config, platformUrl); } + private void resolveKasAllowlist(Config.TDFReaderConfig config) throws SDKException { + if (config.ignoreKasAllowlist + || (config.kasAllowlist != null && !config.kasAllowlist.isEmpty())) { + return; + } + + var cached = kasAllowlistCache.get(platformUrl); + if (cached != null) { + config.kasAllowlist = cached; + return; + } + + var request = ListKeyAccessServersRequest.newBuilder().build(); + ListKeyAccessServersResponse response; + try { + response = RequestHelper.getOrThrow( + services.kasRegistry().listKeyAccessServersBlocking(request, Collections.emptyMap()).execute()); + } catch (Exception e) { + throw new SDKException("error getting kas servers", e); + } + + var allowlist = new HashSet(); + for (var entry : response.getKeyAccessServersList()) { + allowlist.add(Config.getKasAddress(entry.getUri())); + } + allowlist.add(Config.getKasAddress(platformUrl)); + + config.kasAllowlist = allowlist; + kasAllowlistCache.store(platformUrl, allowlist); + } + public Manifest createTDF(InputStream payload, OutputStream outputStream, Config.TDFConfig config) throws SDKException, IOException { var tdf = new TDF(services); return tdf.createTDF(payload, outputStream, config).getManifest(); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/KASAllowlistCacheTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/KASAllowlistCacheTest.java new file mode 100644 index 00000000..730afdec --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/KASAllowlistCacheTest.java @@ -0,0 +1,89 @@ +package io.opentdf.platform.sdk; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class KASAllowlistCacheTest { + + private KASAllowlistCache cache; + + @BeforeEach + void setUp() { + cache = new KASAllowlistCache(); + } + + @Test + void testStoreAndGet_WithinTimeLimit() { + Set allowlist = Set.of("https://kas1.example.org", "https://kas2.example.org"); + cache.store("https://platform.example.org", allowlist); + + Set result = cache.get("https://platform.example.org"); + + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains("https://kas1.example.org")); + assertTrue(result.contains("https://kas2.example.org")); + } + + @Test + void testStoreAndGet_AfterTimeLimit() { + Set allowlist = Set.of("https://kas.example.org"); + cache.store("https://platform.example.org", allowlist); + + TimeStampedAllowList expired = new TimeStampedAllowList(allowlist, LocalDateTime.now().minus(6, ChronoUnit.MINUTES)); + cache.cache.put("https://platform.example.org", expired); + + Set result = cache.get("https://platform.example.org"); + + assertNull(result); + } + + @Test + void testGet_EmptyCache() { + Set result = cache.get("https://platform.example.org"); + assertNull(result); + } + + @Test + void testGet_DifferentKey() { + Set allowlist = Set.of("https://kas.example.org"); + cache.store("https://platform.example.org", allowlist); + + Set result = cache.get("https://other.example.org"); + + assertNull(result); + } + + @Test + void testClearCache() { + Set allowlist = Set.of("https://kas.example.org"); + cache.store("https://platform.example.org", allowlist); + + cache.clear(); + + Set result = cache.get("https://platform.example.org"); + assertNull(result); + } + + @Test + void testStoreMultipleAndGet() { + Set allowlist1 = Set.of("https://kas1.example.org"); + Set allowlist2 = Set.of("https://kas2.example.org"); + cache.store("https://platform1.example.org", allowlist1); + cache.store("https://platform2.example.org", allowlist2); + + Set result1 = cache.get("https://platform1.example.org"); + Set result2 = cache.get("https://platform2.example.org"); + + assertNotNull(result1); + assertTrue(result1.contains("https://kas1.example.org")); + assertNotNull(result2); + assertTrue(result2.contains("https://kas2.example.org")); + } +} From 5b27531bccae25c9dedd3282709d81dc43ae2733 Mon Sep 17 00:00:00 2001 From: Eugene Yakhnenko Date: Thu, 20 Aug 2026 08:21:41 -0700 Subject: [PATCH 2/4] fix(sdk): store defensive copies in KASAllowlistCache Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eugene Yakhnenko --- .../java/io/opentdf/platform/sdk/KASAllowlistCache.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java index de8d58ff..e8b38937 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java @@ -5,7 +5,9 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -38,12 +40,13 @@ public Set get(String platformURL) { } log.debug("successfully returned allowlist for platformURL = [{}]", platformURL); - return cachedValue.allowlist; + return new HashSet<>(cachedValue.allowlist); } public void store(String platformURL, Set allowlist) { log.debug("storing allowlist into the cache for platformURL = [{}]", platformURL); - cache.put(platformURL, new TimeStampedAllowList(allowlist, LocalDateTime.now())); + cache.put(platformURL, new TimeStampedAllowList( + Collections.unmodifiableSet(new HashSet<>(allowlist)), LocalDateTime.now())); } } From 2174dd7f12784be9c297a9fd56918ceef05139a0 Mon Sep 17 00:00:00 2001 From: Eugene Yakhnenko Date: Thu, 20 Aug 2026 08:34:43 -0700 Subject: [PATCH 3/4] chore(sdk): add comment noting KASKeyCache consistency Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eugene Yakhnenko --- sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java index e8b38937..36456173 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java @@ -11,6 +11,7 @@ import java.util.Map; import java.util.Set; +// Mirrors the KASKeyCache pattern: null returns, LocalDateTime.now(), and 5-minute TTL. class KASAllowlistCache { private static final Logger log = LoggerFactory.getLogger(KASAllowlistCache.class); Map cache; From b5254ca4cd64ba0dd612d2b3a3d9b2929806aede Mon Sep 17 00:00:00 2001 From: Eugene Yakhnenko Date: Thu, 20 Aug 2026 11:38:43 -0700 Subject: [PATCH 4/4] fix(sdk): use ConcurrentHashMap and narrow catch scope Co-Authored-By: Claude Opus 4.6 Signed-off-by: Eugene Yakhnenko --- .../io/opentdf/platform/sdk/KASAllowlistCache.java | 10 +++------- sdk/src/main/java/io/opentdf/platform/sdk/SDK.java | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java index 36456173..d467204c 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java @@ -6,22 +6,18 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; // Mirrors the KASKeyCache pattern: null returns, LocalDateTime.now(), and 5-minute TTL. class KASAllowlistCache { private static final Logger log = LoggerFactory.getLogger(KASAllowlistCache.class); - Map cache; - - public KASAllowlistCache() { - this.cache = new HashMap<>(); - } + final Map cache = new ConcurrentHashMap<>(); public void clear() { - this.cache = new HashMap<>(); + cache.clear(); } public Set get(String platformURL) { diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java index f29d93b4..da78b58b 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -163,7 +163,7 @@ private void resolveKasAllowlist(Config.TDFReaderConfig config) throws SDKExcept try { response = RequestHelper.getOrThrow( services.kasRegistry().listKeyAccessServersBlocking(request, Collections.emptyMap()).execute()); - } catch (Exception e) { + } catch (com.connectrpc.ConnectException e) { throw new SDKException("error getting kas servers", e); }