From 01cd8325b3b4192e896f864abb6efe559c8d6849 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 13 Aug 2024 11:01:59 -0400
Subject: [PATCH 01/23] add it to the manifest

---
 sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java | 1 +
 1 file changed, 1 insertion(+)

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
index e0450137..ef30f72c 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
@@ -134,6 +134,7 @@ static public class KeyAccess {
 
         public String encryptedMetadata;
         public String kid;
+        public String sid;
 
         @Override
         public boolean equals(Object o) {

From 35f0a71c594682ecea47069fcc7b9d29aafa4e70 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 13 Aug 2024 11:46:05 -0400
Subject: [PATCH 02/23] add split logic on decrypt

---
 .../java/io/opentdf/platform/sdk/Config.java  | 10 +++++
 .../java/io/opentdf/platform/sdk/TDF.java     | 45 ++++++++++++++++++-
 2 files changed, 53 insertions(+), 2 deletions(-)

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
index 15452d95..875bf71d 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
@@ -53,6 +53,16 @@ public AssertionConfig() {
         }
     }
 
+    public static class SplitStep {
+        public String kas;
+        public String splitID;
+
+        public SplitStep(String kas, String sid) {
+            this.kas = kas;
+            this.splitID = sid;
+          }
+    }
+
     public static class TDFConfig {
         public int defaultSegmentSize;
         public boolean enableEncryption;
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index 1d18732f..f60d19f4 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -13,6 +13,9 @@
 import com.nimbusds.jose.crypto.RSASSAVerifier;
 import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.SignedJWT;
+
+import io.opentdf.platform.sdk.Config.SplitStep;
+
 import com.nimbusds.jose.crypto.RSASSASigner;
 import com.nimbusds.jose.crypto.MACSigner;
 import org.apache.commons.codec.DecoderException;
@@ -495,22 +498,44 @@ private void fillInPublicKeyInfo(List<Config.KASInfo> kasInfoList, SDK.KAS kas)
     }
 
     public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionConfig, SDK.KAS kas) throws NotValidateRootSignature, SegmentSizeMismatch,
-            IOException, FailedToCreateGMAC, JOSEException, ParseException, NoSuchAlgorithmException, DecoderException {
+            IOException, FailedToCreateGMAC, JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, Exception {
 
         TDFReader tdfReader = new TDFReader(tdf);
         String manifestJson = tdfReader.manifest();
         Manifest manifest = gson.fromJson(manifestJson, Manifest.class);
         byte[] payloadKey = new byte[GCM_KEY_SIZE];
         String unencryptedMetadata = null;
+        
+        Map<String, Boolean> knownSplits = new HashMap<>();
+        Map<String, Boolean> foundSplits = new HashMap<>();
+        Map<Config.SplitStep, Exception> skippedSplits = new HashMap<>();
+        boolean mixedSplits = manifest.encryptionInformation.keyAccessObj.size() > 1 && !manifest.encryptionInformation.keyAccessObj.get(0).sid.isEmpty();
 
         MessageDigest digest =  MessageDigest.getInstance("SHA-256");
 
         if (manifest.payload.isEncrypted) {
             for (Manifest.KeyAccess keyAccess: manifest.encryptionInformation.keyAccessObj) {
-                var unwrappedKey = kas.unwrap(keyAccess, manifest.encryptionInformation.policy);
+                Config.SplitStep ss = new Config.SplitStep(keyAccess.url, keyAccess.sid);
+                byte[] unwrappedKey;
+                if (!mixedSplits) {
+                    unwrappedKey = kas.unwrap(keyAccess, manifest.encryptionInformation.policy);
+                } else {
+                    knownSplits.put(ss.splitID, true);
+                    if (foundSplits.get(ss.splitID)){
+                        continue;
+                    }
+                    try {
+                        unwrappedKey = kas.unwrap(keyAccess, manifest.encryptionInformation.policy);
+                    } catch (Exception e) {
+                        skippedSplits.put(ss, e);
+                        continue;
+                    }
+                }
+                
                 for (int index = 0; index < unwrappedKey.length; index++) {
                     payloadKey[index] ^= unwrappedKey[index];
                 }
+                foundSplits.put(ss.splitID, true);
 
                 if (keyAccess.encryptedMetadata != null && !keyAccess.encryptedMetadata.isEmpty()) {
                     AesGcm aesGcm = new AesGcm(unwrappedKey);
@@ -528,6 +553,22 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
                     unencryptedMetadata = new String(decrypted, StandardCharsets.UTF_8);
                 }
             }
+
+            if (mixedSplits && knownSplits.size() > foundSplits.size()) {
+                List<Exception> exceptionList = new ArrayList<>(skippedSplits.size() + 1);
+                exceptionList.add(new Exception("splitKey.unable to reconstruct split key: " + skippedSplits));
+                
+                for (Map.Entry<SplitStep, Exception> entry : skippedSplits.entrySet()) {
+                    exceptionList.add(entry.getValue());
+                }
+                
+                StringBuilder combinedMessage = new StringBuilder();
+                for (Exception e : exceptionList) {
+                    combinedMessage.append(e.getMessage()).append("\n");
+                }
+                
+                throw new Exception(combinedMessage.toString());
+            }
         }
 
         // Validate root signature

From ec5e91bcdeb44048045b02106356f0e631b3e576 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 13 Aug 2024 11:49:47 -0400
Subject: [PATCH 03/23] update cli exception handling

---
 cmdline/src/main/java/io/opentdf/platform/Command.java | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java
index 8303d22c..31324803 100644
--- a/cmdline/src/main/java/io/opentdf/platform/Command.java
+++ b/cmdline/src/main/java/io/opentdf/platform/Command.java
@@ -84,7 +84,7 @@ private SDK buildSDK() {
     void decrypt(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) throws IOException,
             InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException,
             BadPaddingException, InvalidKeyException, TDF.FailedToCreateGMAC,
-            JOSEException, ParseException, NoSuchAlgorithmException, DecoderException {
+            JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, Exception {
         var sdk = buildSDK();
         try (var in = FileChannel.open(tdfPath, StandardOpenOption.READ)) {
             try (var stdout = new BufferedOutputStream(System.out)) {
@@ -95,7 +95,7 @@ void decrypt(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) th
     }
     @CommandLine.Command(name = "metadata")
     void readMetadata(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) throws IOException,
-            TDF.FailedToCreateGMAC, JOSEException, NoSuchAlgorithmException, ParseException, DecoderException {
+            TDF.FailedToCreateGMAC, JOSEException, NoSuchAlgorithmException, ParseException, DecoderException, Exception {
         var sdk = buildSDK();
 
         try (var in = FileChannel.open(tdfPath, StandardOpenOption.READ)) {

From 484dcaa60d62ef2df68a62388cb3415ba903b020 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 13 Aug 2024 11:56:57 -0400
Subject: [PATCH 04/23] make custom exception type

---
 cmdline/src/main/java/io/opentdf/platform/Command.java |  6 ++++--
 sdk/src/main/java/io/opentdf/platform/sdk/TDF.java     | 10 ++++++++--
 2 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java
index 31324803..38a832c8 100644
--- a/cmdline/src/main/java/io/opentdf/platform/Command.java
+++ b/cmdline/src/main/java/io/opentdf/platform/Command.java
@@ -3,6 +3,8 @@
 import com.nimbusds.jose.JOSEException;
 import io.opentdf.platform.sdk.*;
 import io.opentdf.platform.sdk.TDF;
+import io.opentdf.platform.sdk.TDF.SplitKeyException;
+
 import org.apache.commons.codec.DecoderException;
 import picocli.CommandLine;
 import picocli.CommandLine.Option;
@@ -84,7 +86,7 @@ private SDK buildSDK() {
     void decrypt(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) throws IOException,
             InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException,
             BadPaddingException, InvalidKeyException, TDF.FailedToCreateGMAC,
-            JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, Exception {
+            JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, SplitKeyException {
         var sdk = buildSDK();
         try (var in = FileChannel.open(tdfPath, StandardOpenOption.READ)) {
             try (var stdout = new BufferedOutputStream(System.out)) {
@@ -95,7 +97,7 @@ void decrypt(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) th
     }
     @CommandLine.Command(name = "metadata")
     void readMetadata(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) throws IOException,
-            TDF.FailedToCreateGMAC, JOSEException, NoSuchAlgorithmException, ParseException, DecoderException, Exception {
+            TDF.FailedToCreateGMAC, JOSEException, NoSuchAlgorithmException, ParseException, DecoderException, SplitKeyException {
         var sdk = buildSDK();
 
         try (var in = FileChannel.open(tdfPath, StandardOpenOption.READ)) {
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index f60d19f4..27dfcc65 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -71,6 +71,12 @@ public TDF() {
 
     private static final Gson gson = new Gson();
 
+    public class SplitKeyException extends Exception { 
+        public SplitKeyException(String errorMessage) {
+            super(errorMessage);
+        }
+    }
+
     public static class DataSizeNotSupported extends RuntimeException {
         public DataSizeNotSupported(String errorMessage) {
             super(errorMessage);
@@ -498,7 +504,7 @@ private void fillInPublicKeyInfo(List<Config.KASInfo> kasInfoList, SDK.KAS kas)
     }
 
     public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionConfig, SDK.KAS kas) throws NotValidateRootSignature, SegmentSizeMismatch,
-            IOException, FailedToCreateGMAC, JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, Exception {
+            IOException, FailedToCreateGMAC, JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, SplitKeyException {
 
         TDFReader tdfReader = new TDFReader(tdf);
         String manifestJson = tdfReader.manifest();
@@ -567,7 +573,7 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
                     combinedMessage.append(e.getMessage()).append("\n");
                 }
                 
-                throw new Exception(combinedMessage.toString());
+                throw new SplitKeyException(combinedMessage.toString());
             }
         }
 

From 4552d078a5b1dd328ad72b82f769679444990b23 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 13 Aug 2024 13:15:19 -0400
Subject: [PATCH 05/23] null check

---
 sdk/src/main/java/io/opentdf/platform/sdk/TDF.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index 27dfcc65..4642b19f 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -527,7 +527,7 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
                     unwrappedKey = kas.unwrap(keyAccess, manifest.encryptionInformation.policy);
                 } else {
                     knownSplits.put(ss.splitID, true);
-                    if (foundSplits.get(ss.splitID)){
+                    if (foundSplits.get(ss.splitID) != null){
                         continue;
                     }
                     try {

From 8b006dd982530e87dc7ee2a7f3d9579abfc1ba76 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 13 Aug 2024 13:33:33 -0400
Subject: [PATCH 06/23] check for null pointer

---
 sdk/src/main/java/io/opentdf/platform/sdk/TDF.java | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index 4642b19f..21056300 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -515,7 +515,9 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
         Map<String, Boolean> knownSplits = new HashMap<>();
         Map<String, Boolean> foundSplits = new HashMap<>();
         Map<Config.SplitStep, Exception> skippedSplits = new HashMap<>();
-        boolean mixedSplits = manifest.encryptionInformation.keyAccessObj.size() > 1 && !manifest.encryptionInformation.keyAccessObj.get(0).sid.isEmpty();
+        boolean mixedSplits = manifest.encryptionInformation.keyAccessObj.size() > 1 &&
+         (manifest.encryptionInformation.keyAccessObj.get(0).sid != null) &&
+          !manifest.encryptionInformation.keyAccessObj.get(0).sid.isEmpty();
 
         MessageDigest digest =  MessageDigest.getInstance("SHA-256");
 

From fb889a3d88ebdab8b89468fc85b966092d84c0d2 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Wed, 14 Aug 2024 13:05:02 -0400
Subject: [PATCH 07/23] encrypt w key splits

---
 .github/workflows/checks.yaml                 |  55 ++++++++
 .../java/io/opentdf/platform/Command.java     |   2 +-
 .../java/io/opentdf/platform/sdk/Config.java  |   2 +
 .../java/io/opentdf/platform/sdk/TDF.java     | 119 ++++++++++++------
 4 files changed, 137 insertions(+), 41 deletions(-)

diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index d3f909ff..63459e6c 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -190,6 +190,61 @@ jobs:
           fi
         working-directory: cmdline
 
+      - name: Start second in background
+        uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635
+        with:
+          run: |
+            <opentdf.yaml >opentdf-beta.yaml yq e '
+              (.server.port = 8282)
+              | (.mode = ["kas"])
+              | (.sdk_config = {"endpoint":"http://localhost:8080","plaintext":true,"client_id":"opentdf","client_secret":"secret"})
+            '
+            && .github/scripts/watch.sh opentdf-beta.yaml ./opentdf --config-file ./opentdf-beta.yaml start
+          wait-on: |
+            tcp:localhost:8282
+          log-output-if: true
+          wait-for: 90s
+          working-directory: platform
+      - name: Make sure that the second platform is up
+        run: |
+          grpcurl -plaintext localhost:8282 list && \
+          grpcurl -plaintext localhost:8282 kas.AccessService/PublicKey
+      - name: Validate multikas through the command line interface
+        run: |
+          printf 'here is some data to encrypt' > data
+
+          java -jar target/cmdline.jar \
+            --client-id=opentdf-sdk \
+            --client-secret=secret \
+            --platform-endpoint=localhost:8080 \
+            -i \
+            encrypt --kas-url=localhost:8080,localhost:8282 -f data -m 'here is some metadata' > test.tdf
+
+          java -jar target/cmdline.jar \
+            --client-id=opentdf-sdk \
+            --client-secret=secret \
+            --platform-endpoint=localhost:8080 \
+            -i \
+            decrypt -f test.tdf > decrypted
+
+          java -jar target/cmdline.jar \
+            --client-id=opentdf-sdk \
+            --client-secret=secret \
+            --platform-endpoint=localhost:8080 \
+            -i \
+            metadata -f test.tdf > metadata
+
+          if ! diff -q data decrypted; then
+            printf 'decrypted data is incorrect [%s]' "$(< decrypted)"
+            exit 1
+          fi
+
+          if [ "$(< metadata)" != 'here is some metadata' ]; then
+            printf 'metadata is incorrect [%s]\n' "$(< metadata)"
+            exit 1
+          fi
+        working-directory: cmdline
+
   platform-xtest:
     permissions:
       contents: read
diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java
index 38a832c8..f0874360 100644
--- a/cmdline/src/main/java/io/opentdf/platform/Command.java
+++ b/cmdline/src/main/java/io/opentdf/platform/Command.java
@@ -51,7 +51,7 @@ class Command {
     @CommandLine.Command(name = "encrypt")
     void encrypt(
             @Option(names = {"-f", "--file"}, defaultValue = Option.NULL_VALUE) Optional<File> file,
-            @Option(names = {"-k", "--kas-url"}, required = true) List<String> kas,
+            @Option(names = {"-k", "--kas-url"}, required = true, split = ",") List<String> kas,
             @Option(names = {"-m", "--metadata"}, defaultValue = Option.NULL_VALUE) Optional<String> metadata) throws
             IOException, JOSEException {
 
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
index 875bf71d..cc276196 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
@@ -77,6 +77,7 @@ public static class TDFConfig {
         public List<Assertion> assertionList;
         public AssertionConfig assertionConfig;
         public String mimeType;
+        public List<SplitStep> splitPlan;
 
         public TDFConfig() {
             this.defaultSegmentSize = DEFAULT_SEGMENT_SIZE;
@@ -88,6 +89,7 @@ public TDFConfig() {
             this.kasInfoList = new ArrayList<>();
             this.assertionList = new ArrayList<>();
             this.mimeType = DEFAULT_MIME_TYPE;
+            this.splitPlan = new ArrayList<>();
         }
     }
 
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index 21056300..bef542e4 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -14,8 +14,6 @@
 import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.SignedJWT;
 
-import io.opentdf.platform.sdk.Config.SplitStep;
-
 import com.nimbusds.jose.crypto.RSASSASigner;
 import com.nimbusds.jose.crypto.MACSigner;
 import org.apache.commons.codec.DecoderException;
@@ -173,57 +171,111 @@ PolicyObject createPolicyObject(List<String> attributes) {
         }
 
         private static final Base64.Encoder encoder = Base64.getEncoder();
-        private void prepareManifest(Config.TDFConfig tdfConfig) {
+        private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
             manifest.encryptionInformation.keyAccessType = kSplitKeyType;
             manifest.encryptionInformation.keyAccessObj =  new ArrayList<>();
 
             PolicyObject policyObject = createPolicyObject(tdfConfig.attributes);
             String base64PolicyObject  = encoder.encodeToString(gson.toJson(policyObject).getBytes(StandardCharsets.UTF_8));
             List<byte[]> symKeys = new ArrayList<>();
+            Map<String, Config.KASInfo> latestKASInfo = new HashMap<>();
+            if (tdfConfig.splitPlan.isEmpty()) {
+                // Default split plan: Split keys across all KASes
+                List<Config.SplitStep> splitPlan = new ArrayList<>(tdfConfig.kasInfoList.size());
+                for (Config.KASInfo kasInfo : tdfConfig.kasInfoList) {
+                    Config.SplitStep step = new Config.SplitStep(kasInfo.URL, "");
+                    if (tdfConfig.kasInfoList.size() > 1) {
+                        step.splitID = String.format("s-%d", splitPlan.size());
+                    }
+                    splitPlan.add(step);
+                    if (kasInfo.PublicKey != null && !kasInfo.PublicKey.isEmpty()) {
+                        latestKASInfo.put(kasInfo.URL, kasInfo);
+                    }
+                }
+                tdfConfig.splitPlan = splitPlan;
+            }
 
+            // Seed anything passed in manually
             for (Config.KASInfo kasInfo: tdfConfig.kasInfoList) {
-                if (kasInfo.PublicKey == null || kasInfo.PublicKey.isEmpty()) {
-                    throw new KasPublicKeyMissing("Kas public key is missing in kas information list");
+                if (kasInfo.PublicKey != null && !kasInfo.PublicKey.isEmpty()) {
+                    latestKASInfo.put(kasInfo.URL, kasInfo);
+                }
+            }
+
+            // split plan: restructure by conjunctions
+            Map<String, List<Config.KASInfo>> conjunction = new HashMap<>();
+            List<String> splitIDs = new ArrayList<>();
+
+            for (Config.SplitStep splitInfo : tdfConfig.splitPlan) {
+                // Public key was passed in with kasInfoList
+                // TODO First look up in attribute information / add to split plan?
+                Config.KASInfo ki = latestKASInfo.get(splitInfo.kas);
+                if (ki == null || ki.PublicKey == null || ki.PublicKey.isBlank()) {
+                    logger.info("no public key provided for KAS at {}, retrieving", splitInfo.kas);
+                    var getKI = new Config.KASInfo();
+                    getKI.URL = splitInfo.kas;
+                    getKI.PublicKey = kas.getPublicKey(getKI);
+                    getKI.KID = kas.getKid(getKI);
+                    latestKASInfo.put(splitInfo.kas, getKI);
+                    ki = getKI;
+                }
+                if (conjunction.containsKey(splitInfo.splitID)) {
+                    conjunction.get(splitInfo.splitID).add(ki);
+                } else {
+                    List<Config.KASInfo> newList = new ArrayList<>();
+                    newList.add(ki);
+                    conjunction.put(splitInfo.splitID, newList);
+                    splitIDs.add(splitInfo.splitID);
                 }
+            }
 
+            for (String splitID: splitIDs) {
                 // Symmetric key
                 byte[] symKey = new byte[GCM_KEY_SIZE];
                 sRandom.nextBytes(symKey);
-
-                Manifest.KeyAccess keyAccess = new Manifest.KeyAccess();
-                keyAccess.keyType = kWrapped;
-                keyAccess.url = kasInfo.URL;
-                keyAccess.kid = kasInfo.KID;
-                keyAccess.protocol = kKasProtocol;
+                symKeys.add(symKey);
 
                 // Add policyBinding
                 var hexBinding = Hex.encodeHexString(CryptoUtils.CalculateSHA256Hmac(symKey, base64PolicyObject.getBytes(StandardCharsets.UTF_8)));
                 var policyBinding = new Manifest.PolicyBinding();
                 policyBinding.alg = kHmacIntegrityAlgorithm;
                 policyBinding.hash = encoder.encodeToString(hexBinding.getBytes(StandardCharsets.UTF_8));
-                keyAccess.policyBinding = policyBinding;
-
-                // Wrap the key with kas public key
-                AsymEncryption asymmetricEncrypt = new AsymEncryption(kasInfo.PublicKey);
-                byte[] wrappedKey = asymmetricEncrypt.encrypt(symKey);
-
-                keyAccess.wrappedKey = encoder.encodeToString(wrappedKey);
+                
 
                 // Add meta data
+                var encryptedMetadata = new String();
                 if(tdfConfig.metaData != null && !tdfConfig.metaData.trim().isEmpty()) {
                     AesGcm aesGcm = new AesGcm(symKey);
                     var encrypted = aesGcm.encrypt(tdfConfig.metaData.getBytes(StandardCharsets.UTF_8));
 
-                    EncryptedMetadata encryptedMetadata = new EncryptedMetadata();
-                    encryptedMetadata.iv = encoder.encodeToString(encrypted.getIv());
-                    encryptedMetadata.ciphertext = encoder.encodeToString(encrypted.asBytes());
+                    EncryptedMetadata em = new EncryptedMetadata();
+                    em.iv = encoder.encodeToString(encrypted.getIv());
+                    em.ciphertext = encoder.encodeToString(encrypted.asBytes());
 
-                    var metadata = gson.toJson(encryptedMetadata);
-                    keyAccess.encryptedMetadata = encoder.encodeToString(metadata.getBytes(StandardCharsets.UTF_8));
+                    var metadata = gson.toJson(em);
+                    encryptedMetadata = encoder.encodeToString(metadata.getBytes(StandardCharsets.UTF_8));
                 }
 
-                symKeys.add(symKey);
-                manifest.encryptionInformation.keyAccessObj.add(keyAccess);
+                for (Config.KASInfo kasInfo: conjunction.get(splitID)){
+                    if (kasInfo.PublicKey == null || kasInfo.PublicKey.isEmpty()) {
+                        throw new KasPublicKeyMissing("Kas public key is missing in kas information list");
+                    }
+
+                    // Wrap the key with kas public key
+                    AsymEncryption asymmetricEncrypt = new AsymEncryption(kasInfo.PublicKey);
+                    byte[] wrappedKey = asymmetricEncrypt.encrypt(symKey);
+
+                    Manifest.KeyAccess keyAccess = new Manifest.KeyAccess();
+                    keyAccess.keyType = kWrapped;
+                    keyAccess.url = kasInfo.URL;
+                    keyAccess.kid = kasInfo.KID;
+                    keyAccess.protocol = kKasProtocol;
+                    keyAccess.policyBinding = policyBinding;
+                    keyAccess.wrappedKey = encoder.encodeToString(wrappedKey);
+                    keyAccess.encryptedMetadata = encryptedMetadata;
+
+                    manifest.encryptionInformation.keyAccessObj.add(keyAccess);
+                }
             }
 
             manifest.encryptionInformation.policy = base64PolicyObject;
@@ -328,10 +380,8 @@ public TDFObject createTDF(InputStream payload,
             throw new KasInfoMissing("kas information is missing");
         }
 
-        fillInPublicKeyInfo(tdfConfig.kasInfoList, kas);
-
         TDFObject tdfObject = new TDFObject();
-        tdfObject.prepareManifest(tdfConfig);
+        tdfObject.prepareManifest(tdfConfig, kas);
 
         long encryptedSegmentSize = tdfConfig.defaultSegmentSize + kGcmIvSize + kAesBlockSize;
         TDFWriter tdfWriter = new TDFWriter(outputStream);
@@ -492,17 +542,6 @@ public TDFObject createTDF(InputStream payload,
         return tdfObject;
     }
 
-    private void fillInPublicKeyInfo(List<Config.KASInfo> kasInfoList, SDK.KAS kas) {
-        for (var kasInfo: kasInfoList) {
-            if (kasInfo.PublicKey != null && !kasInfo.PublicKey.isBlank()) {
-                continue;
-            }
-            logger.info("no public key provided for KAS at {}, retrieving", kasInfo.URL);
-            kasInfo.PublicKey = kas.getPublicKey(kasInfo);
-            kasInfo.KID = kas.getKid(kasInfo);
-        }
-    }
-
     public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionConfig, SDK.KAS kas) throws NotValidateRootSignature, SegmentSizeMismatch,
             IOException, FailedToCreateGMAC, JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, SplitKeyException {
 
@@ -566,7 +605,7 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
                 List<Exception> exceptionList = new ArrayList<>(skippedSplits.size() + 1);
                 exceptionList.add(new Exception("splitKey.unable to reconstruct split key: " + skippedSplits));
                 
-                for (Map.Entry<SplitStep, Exception> entry : skippedSplits.entrySet()) {
+                for (Map.Entry<Config.SplitStep, Exception> entry : skippedSplits.entrySet()) {
                     exceptionList.add(entry.getValue());
                 }
                 

From 03fae78dfe34125a8b49096604cca33b27461a4f Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Wed, 14 Aug 2024 13:26:14 -0400
Subject: [PATCH 08/23] try to fix test, exception fix, sets

---
 .github/workflows/checks.yaml                      |  6 +++---
 .../src/main/java/io/opentdf/platform/Command.java |  5 ++---
 sdk/src/main/java/io/opentdf/platform/sdk/TDF.java | 14 +++++++-------
 3 files changed, 12 insertions(+), 13 deletions(-)

diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index 63459e6c..cf8f237f 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -190,10 +190,10 @@ jobs:
           fi
         working-directory: cmdline
 
-      - name: Start second in background
-        uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635
+      - uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635
+        name: start another KAS server in background
         with:
-          run: |
+          run: >
             <opentdf.yaml >opentdf-beta.yaml yq e '
               (.server.port = 8282)
               | (.mode = ["kas"])
diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java
index f0874360..ddb9d61f 100644
--- a/cmdline/src/main/java/io/opentdf/platform/Command.java
+++ b/cmdline/src/main/java/io/opentdf/platform/Command.java
@@ -3,7 +3,6 @@
 import com.nimbusds.jose.JOSEException;
 import io.opentdf.platform.sdk.*;
 import io.opentdf.platform.sdk.TDF;
-import io.opentdf.platform.sdk.TDF.SplitKeyException;
 
 import org.apache.commons.codec.DecoderException;
 import picocli.CommandLine;
@@ -86,7 +85,7 @@ private SDK buildSDK() {
     void decrypt(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) throws IOException,
             InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException,
             BadPaddingException, InvalidKeyException, TDF.FailedToCreateGMAC,
-            JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, SplitKeyException {
+            JOSEException, ParseException, NoSuchAlgorithmException, DecoderException {
         var sdk = buildSDK();
         try (var in = FileChannel.open(tdfPath, StandardOpenOption.READ)) {
             try (var stdout = new BufferedOutputStream(System.out)) {
@@ -97,7 +96,7 @@ void decrypt(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) th
     }
     @CommandLine.Command(name = "metadata")
     void readMetadata(@Option(names = {"-f", "--file"}, required = true) Path tdfPath) throws IOException,
-            TDF.FailedToCreateGMAC, JOSEException, NoSuchAlgorithmException, ParseException, DecoderException, SplitKeyException {
+            TDF.FailedToCreateGMAC, JOSEException, NoSuchAlgorithmException, ParseException, DecoderException {
         var sdk = buildSDK();
 
         try (var in = FileChannel.open(tdfPath, StandardOpenOption.READ)) {
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index bef542e4..1beb902f 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -69,7 +69,7 @@ public TDF() {
 
     private static final Gson gson = new Gson();
 
-    public class SplitKeyException extends Exception { 
+    public class SplitKeyException extends IOException { 
         public SplitKeyException(String errorMessage) {
             super(errorMessage);
         }
@@ -543,7 +543,7 @@ public TDFObject createTDF(InputStream payload,
     }
 
     public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionConfig, SDK.KAS kas) throws NotValidateRootSignature, SegmentSizeMismatch,
-            IOException, FailedToCreateGMAC, JOSEException, ParseException, NoSuchAlgorithmException, DecoderException, SplitKeyException {
+            IOException, FailedToCreateGMAC, JOSEException, ParseException, NoSuchAlgorithmException, DecoderException {
 
         TDFReader tdfReader = new TDFReader(tdf);
         String manifestJson = tdfReader.manifest();
@@ -551,8 +551,8 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
         byte[] payloadKey = new byte[GCM_KEY_SIZE];
         String unencryptedMetadata = null;
         
-        Map<String, Boolean> knownSplits = new HashMap<>();
-        Map<String, Boolean> foundSplits = new HashMap<>();
+        Set<String> knownSplits = new HashSet<String>();
+        Set<String> foundSplits = new HashSet<String>();;
         Map<Config.SplitStep, Exception> skippedSplits = new HashMap<>();
         boolean mixedSplits = manifest.encryptionInformation.keyAccessObj.size() > 1 &&
          (manifest.encryptionInformation.keyAccessObj.get(0).sid != null) &&
@@ -567,8 +567,8 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
                 if (!mixedSplits) {
                     unwrappedKey = kas.unwrap(keyAccess, manifest.encryptionInformation.policy);
                 } else {
-                    knownSplits.put(ss.splitID, true);
-                    if (foundSplits.get(ss.splitID) != null){
+                    knownSplits.add(unencryptedMetadata);
+                    if (foundSplits.contains(ss.splitID)){
                         continue;
                     }
                     try {
@@ -582,7 +582,7 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
                 for (int index = 0; index < unwrappedKey.length; index++) {
                     payloadKey[index] ^= unwrappedKey[index];
                 }
-                foundSplits.put(ss.splitID, true);
+                foundSplits.add(ss.splitID);
 
                 if (keyAccess.encryptedMetadata != null && !keyAccess.encryptedMetadata.isEmpty()) {
                     AesGcm aesGcm = new AesGcm(unwrappedKey);

From ee193e3abfcb4ac863a3eeee210b5214aec3c54f Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Wed, 14 Aug 2024 13:35:19 -0400
Subject: [PATCH 09/23] fix test

---
 .github/workflows/checks.yaml | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index cf8f237f..baf65236 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -199,7 +199,7 @@ jobs:
               | (.mode = ["kas"])
               | (.sdk_config = {"endpoint":"http://localhost:8080","plaintext":true,"client_id":"opentdf","client_secret":"secret"})
             '
-            && .github/scripts/watch.sh opentdf-beta.yaml ./opentdf --config-file ./opentdf-beta.yaml start
+            && go run ./service --config-file ./opentdf-beta.yaml start
           wait-on: |
             tcp:localhost:8282
           log-output-if: true
@@ -207,7 +207,6 @@ jobs:
           working-directory: platform
       - name: Make sure that the second platform is up
         run: |
-          grpcurl -plaintext localhost:8282 list && \
           grpcurl -plaintext localhost:8282 kas.AccessService/PublicKey
       - name: Validate multikas through the command line interface
         run: |

From 44a8d963543863bcc7156d351221a0ee7802c8fc Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Fri, 16 Aug 2024 01:12:33 -0400
Subject: [PATCH 10/23] set up attributes client

---
 .../platform/sdk/AttributesClient.java        | 90 +++++++++++++++++++
 .../java/io/opentdf/platform/sdk/SDK.java     | 16 +++-
 .../io/opentdf/platform/sdk/SDKBuilder.java   |  5 +-
 3 files changed, 105 insertions(+), 6 deletions(-)
 create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/AttributesClient.java

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AttributesClient.java b/sdk/src/main/java/io/opentdf/platform/sdk/AttributesClient.java
new file mode 100644
index 00000000..15a5d33b
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/AttributesClient.java
@@ -0,0 +1,90 @@
+package io.opentdf.platform.sdk;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.function.Function;
+
+import io.grpc.ManagedChannel;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
+import io.opentdf.platform.policy.attributes.AttributesServiceGrpc;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
+
+import static java.lang.String.format;
+
+
+public class AttributesClient implements SDK.AttributesService {
+
+    private final ManagedChannel channel;
+
+    /***
+     * A client that communicates with KAS
+     * @param channelFactory A function that produces channels that can be used to communicate
+     * @param dpopKey
+     */
+    public AttributesClient(ManagedChannel channel) {
+        this.channel = channel;
+    }
+
+
+    @Override
+    public synchronized void close() {
+        var entries = new ArrayList<>(stubs.values());
+        stubs.clear();
+        for (var entry: entries) {
+            entry.channel.shutdownNow();
+        }
+    }
+
+    private String normalizeAddress(String urlString) {
+        URL url;
+        try {
+            url = new URL(urlString);
+        } catch (MalformedURLException e) {
+            // if there is no protocol then they either gave us
+            // a correct address or one we don't know how to fix
+            return urlString;
+        }
+
+        // otherwise we take the specified port or default
+        // based on whether the URL uses a scheme that
+        // implies TLS
+        int port;
+        if (url.getPort() == -1) {
+            if ("http".equals(url.getProtocol())) {
+                port = 80;
+            } else {
+                port = 443;
+            }
+        } else {
+            port = url.getPort();
+        }
+
+        return format("%s:%d", url.getHost(), port);
+    }
+
+
+    private final HashMap<String, CacheEntry> stubs = new HashMap<>();
+    private static class CacheEntry {
+        final ManagedChannel channel;
+        final AttributesServiceGrpc.AttributesServiceBlockingStub stub;
+        private CacheEntry(ManagedChannel channel, AttributesServiceGrpc.AttributesServiceBlockingStub stub) {
+            this.channel = channel;
+            this.stub = stub;
+        }
+    }
+
+    // make this protected so we can test the address normalization logic
+    synchronized AttributesServiceGrpc.AttributesServiceBlockingStub getStub() {
+        return AttributesServiceGrpc.newBlockingStub(channel);
+    }
+
+
+    @Override
+    public GetAttributeValuesByFqnsResponse getAttributeValuesByFqn(GetAttributeValuesByFqnsRequest request) {
+        return getStub().getAttributeValuesByFqns(request);
+    } 
+
+}
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 df94dc59..4e32b1fb 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
@@ -5,6 +5,7 @@
 import io.opentdf.platform.authorization.AuthorizationServiceGrpc;
 import io.opentdf.platform.authorization.AuthorizationServiceGrpc.AuthorizationServiceFutureStub;
 import io.opentdf.platform.policy.attributes.AttributesServiceGrpc;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
 import io.opentdf.platform.policy.attributes.AttributesServiceGrpc.AttributesServiceFutureStub;
 import io.opentdf.platform.policy.namespaces.NamespaceServiceGrpc;
 import io.opentdf.platform.policy.namespaces.NamespaceServiceGrpc.NamespaceServiceFutureStub;
@@ -13,8 +14,11 @@
 import io.opentdf.platform.policy.subjectmapping.SubjectMappingServiceGrpc;
 import io.opentdf.platform.policy.subjectmapping.SubjectMappingServiceGrpc.SubjectMappingServiceFutureStub;
 import io.opentdf.platform.sdk.nanotdf.NanoTDFType;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
 
 import javax.net.ssl.TrustManager;
+
+import java.util.List;
 import java.util.Optional;
 
 /**
@@ -39,17 +43,20 @@ public interface KAS extends AutoCloseable {
         byte[] unwrapNanoTDF(NanoTDFType.ECCurve curve, String header, String kasURL);
     }
 
+    public interface AttributesService extends AutoCloseable {
+        GetAttributeValuesByFqnsResponse getAttributeValuesByFqn(GetAttributeValuesByFqnsRequest request);
+    }
+
     // TODO: add KAS
     public interface Services extends AutoCloseable {
         AuthorizationServiceFutureStub authorization();
-        AttributesServiceFutureStub attributes();
+        AttributesService attributes();
         NamespaceServiceFutureStub namespaces();
         SubjectMappingServiceFutureStub subjectMappings();
         ResourceMappingServiceFutureStub resourceMappings();
         KAS kas();
 
-        static Services newServices(ManagedChannel channel, KAS kas) {
-            var attributeService = AttributesServiceGrpc.newFutureStub(channel);
+        static Services newServices(ManagedChannel channel, KAS kas, AttributesService attributeService) {
             var namespaceService = NamespaceServiceGrpc.newFutureStub(channel);
             var subjectMappingService = SubjectMappingServiceGrpc.newFutureStub(channel);
             var resourceMappingService = ResourceMappingServiceGrpc.newFutureStub(channel);
@@ -59,11 +66,12 @@ static Services newServices(ManagedChannel channel, KAS kas) {
                 @Override
                 public void close() throws Exception {
                     channel.shutdownNow();
+                    attributeService.close();
                     kas.close();
                 }
 
                 @Override
-                public AttributesServiceFutureStub attributes() {
+                public AttributesService attributes() {
                     return attributeService;
                 }
 
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
index a29a0040..d7c1fde5 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
@@ -202,11 +202,12 @@ ServicesAndInternals buildServices() {
             channel = getManagedChannelBuilder(platformEndpoint).intercept(authInterceptor).build();
             managedChannelFactory = (String endpoint) -> getManagedChannelBuilder(endpoint).intercept(authInterceptor).build();
         }
-        var client = new KASClient(managedChannelFactory, dpopKey);
+        var kasclient = new KASClient(managedChannelFactory, dpopKey);
+        var attrclient = new AttributesClient(channel);
         return new ServicesAndInternals(
                 authInterceptor,
                 sslFactory == null ? null : sslFactory.getTrustManager().orElse(null),
-                SDK.Services.newServices(channel, client)
+                SDK.Services.newServices(channel, kasclient, attrclient)
         );
     }
 

From 895592e467a904b22e0b6c8b4a2a8e218cf7ecae Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Mon, 19 Aug 2024 11:16:08 -0400
Subject: [PATCH 11/23] add autoconfigure

---
 .../opentdf/platform/sdk/Autoconfigure.java   | 732 ++++++++++++++++++
 .../java/io/opentdf/platform/sdk/Config.java  |  12 +-
 .../java/io/opentdf/platform/sdk/TDF.java     |  12 +-
 .../platform/sdk/AutoconfigureTest.java       | 555 +++++++++++++
 4 files changed, 1294 insertions(+), 17 deletions(-)
 create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
 create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
new file mode 100644
index 00000000..b6a8e928
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -0,0 +1,732 @@
+package io.opentdf.platform.sdk;
+
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringJoiner;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Supplier;
+
+import io.opentdf.platform.policy.KeyAccessServer;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse.AttributeAndValue;
+import io.opentdf.platform.policy.Attribute;
+import io.opentdf.platform.policy.Value;
+import io.opentdf.platform.policy.AttributeValueSelector;
+import io.opentdf.platform.policy.AttributeRuleTypeEnum;
+
+// Error handling class
+class AutoConfigureException extends Exception {
+    public AutoConfigureException(String message) {
+        super(message);
+    }
+}
+
+// Attribute rule types: operators!
+class RuleTypes {
+    public static final String HIERARCHY = "hierarchy";
+    public static final String ALL_OF = "allOf";
+    public static final String ANY_OF = "anyOf";
+    public static final String UNSPECIFIED = "unspecified";
+    public static final String EMPTY_TERM = "DEFAULT";
+}
+
+
+public class Autoconfigure {
+
+    public static Logger logger = LoggerFactory.getLogger(TDF.class);
+
+    public static class SplitStep {
+        public String kas;
+        public String splitID;
+
+        public SplitStep(String kas, String splitId) {
+            this.kas = kas;
+            this.splitID = splitId;
+        }
+    }
+
+    // Utility class for an attribute name FQN.
+    public static class AttributeNameFQN {
+        private final String url;
+        private final String key;
+
+        public AttributeNameFQN(String url) throws AutoConfigureException {
+            Pattern pattern = Pattern.compile("^(https?://[\\w./-]+)/attr/([^/\\s]*)$");
+            Matcher matcher = pattern.matcher(url);
+            if (!matcher.find() || matcher.group(1) == null || matcher.group(2) == null) {
+                throw new AutoConfigureException("invalid type: attribute regex fail");
+            }
+
+            try {
+                URLDecoder.decode(matcher.group(2), StandardCharsets.UTF_8.name());
+            } catch (Exception e) {
+                throw new AutoConfigureException("invalid type: error in attribute name [" + matcher.group(2) + "]");
+            }
+
+            this.url = url;
+            this.key = url.toLowerCase();
+        }
+
+        @Override
+        public String toString() {
+            return url;
+        }
+
+        public AttributeValueFQN select(String value) throws AutoConfigureException {
+            String newUrl = String.format("%s/value/%s", url, URLEncoder.encode(value, StandardCharsets.UTF_8));
+            return new AttributeValueFQN(newUrl);
+        }
+
+        public String prefix() {
+            return url;
+        }
+
+        public String getKey() {
+            return key;
+        }
+
+        public String authority() throws AutoConfigureException {
+            Pattern pattern = Pattern.compile("^(https?://[\\w./-]+)/attr/[^/\\s]*$");
+            Matcher matcher = pattern.matcher(url);
+            if (!matcher.find()) {
+                throw new AutoConfigureException("invalid type");
+            }
+            return matcher.group(1);
+        }
+
+        public String name() throws AutoConfigureException {
+            Pattern pattern = Pattern.compile("^https?://[\\w./-]+/attr/([^/\\s]*)$");
+            Matcher matcher = pattern.matcher(url);
+            if (!matcher.find()) {
+                throw new AutoConfigureException("invalid attribute");
+            }
+            try {
+                return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8.name());
+            } catch (Exception e) {
+                throw new AutoConfigureException("invalid type");
+            }
+        }
+    }
+
+    // Utility class for an attribute value FQN.
+    public static class AttributeValueFQN {
+        private final String url;
+        private final String key;
+
+        public AttributeValueFQN(String url) throws AutoConfigureException {
+            Pattern pattern = Pattern.compile("^(https?://[\\w./-]+)/attr/(\\S*)/value/(\\S*)$");
+            Matcher matcher = pattern.matcher(url);
+            if (!matcher.find() || matcher.group(1) == null || matcher.group(2) == null || matcher.group(3) == null) {
+                throw new AutoConfigureException("invalid type: attribute regex fail for [" + url + "]");
+            }
+
+            try {
+                URLDecoder.decode(matcher.group(2), StandardCharsets.UTF_8.name());
+                URLDecoder.decode(matcher.group(3), StandardCharsets.UTF_8.name());
+            } catch (Exception e) {
+                throw new AutoConfigureException("invalid type: error in attribute or value");
+            }
+
+            this.url = url;
+            this.key = url.toLowerCase();
+        }
+
+        @Override
+        public String toString() {
+            return url;
+        }
+
+        public String getKey() {
+            return key;
+        }
+
+        public String authority() throws AutoConfigureException {
+            Pattern pattern = Pattern.compile("^(https?://[\\w./-]+)/attr/\\S*/value/\\S*$");
+            Matcher matcher = pattern.matcher(url);
+            if (!matcher.find()) {
+                throw new AutoConfigureException("invalid type");
+            }
+            return matcher.group(1);
+        }
+
+        public AttributeNameFQN prefix() throws AutoConfigureException {
+            Pattern pattern = Pattern.compile("^(https?://[\\w./-]+/attr/\\S*)/value/\\S*$");
+            Matcher matcher = pattern.matcher(url);
+            if (!matcher.find()) {
+                throw new AutoConfigureException("invalid type");
+            }
+            return new AttributeNameFQN(matcher.group(1));
+        }
+
+        public String value() throws AutoConfigureException {
+            Pattern pattern = Pattern.compile("^https?://[\\w./-]+/attr/\\S*/value/(\\S*)$");
+            Matcher matcher = pattern.matcher(url);
+            if (!matcher.find()) {
+                throw new AutoConfigureException("invalid type");
+            }
+            try {
+                return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8.name());
+            } catch (Exception e) {
+                throw new AutoConfigureException("invalid type");
+            }
+        }
+
+        public String name() throws AutoConfigureException {
+            Pattern pattern = Pattern.compile("^https?://[\\w./-]+/attr/(\\S*)/value/\\S*$");
+            Matcher matcher = pattern.matcher(url);
+            if (!matcher.find()) {
+                throw new AutoConfigureException("invalid attributeInstance");
+            }
+            try {
+                return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8.name());
+            } catch (Exception e) {
+                throw new AutoConfigureException("invalid attributeInstance");
+            }
+        }
+    }
+
+    public static class KeyAccessGrant {
+        public Attribute attr;
+        public List<String> kases;
+
+        public KeyAccessGrant(Attribute attr, List<String> kases) {
+            this.attr = attr;
+            this.kases = kases;
+        }
+    }
+
+    // Structure capable of generating a split plan from a given set of data tags.
+    public static class Granter {
+        private final List<AttributeValueFQN> policy;
+        private Map<String, KeyAccessGrant> grants = new HashMap<>();
+
+        public Granter(List<AttributeValueFQN> policy) {
+            this.policy = policy;
+        }
+
+        public Granter(List<AttributeValueFQN> policy, Map<String, KeyAccessGrant> grants) {
+            this.policy = policy;
+            this.grants = grants;
+        }
+
+        public  Map<String, KeyAccessGrant> getGrants() {
+            return grants;
+        }
+
+        public List<AttributeValueFQN> getPolicy() {
+            return policy;
+        }
+
+        public void addGrant(AttributeValueFQN fqn, String kas, Attribute attr) {
+            grants.computeIfAbsent(fqn.key, k -> new KeyAccessGrant(attr, new ArrayList<>()))
+                .kases.add(kas);
+        }
+
+        public void addAllGrants(AttributeValueFQN fqn, List<KeyAccessServer> gs, Attribute attr) {
+            if (gs.isEmpty()) {
+                grants.putIfAbsent(fqn.key, new KeyAccessGrant(attr, new ArrayList<>()));
+            } else {
+                for (KeyAccessServer g : gs) {
+                    if (g != null) {
+                        addGrant(fqn, g.getUri(), attr);
+                    }
+                }
+            }
+        }
+
+        public KeyAccessGrant byAttribute(AttributeValueFQN fqn) {
+            return grants.get(fqn.key);
+        }
+
+        public interface StringOperator {
+            public String op();
+        }
+
+
+        public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID) throws Exception {
+            AttributeBooleanExpression b = constructAttributeBoolean();
+            BooleanKeyExpression k = insertKeysForAttribute(b);
+            if (k == null) {
+                throw new AutoConfigureException("Error inserting keys for attribute");
+            }
+
+            k = k.reduce();
+            int l = k.len();
+            if (l == 0) {
+                // default behavior: split key across all default KAS
+                if (defaultKas.isEmpty()) {
+                    throw new AutoConfigureException("no default KAS specified; required for grantless plans");
+                } else if (defaultKas.size() == 1) {
+                    return Collections.singletonList(new SplitStep(defaultKas.get(0), ""));
+                } else {
+                    List<SplitStep> result = new ArrayList<>();
+                    for (String kas : defaultKas) {
+                        result.add(new SplitStep(kas, genSplitID.get()));
+                    }
+                    return result;
+                }
+            }
+
+            List<SplitStep> steps = new ArrayList<>();
+            for (KeyClause v : k.values) {
+                String splitID = (l > 1) ? genSplitID.get() : "";
+                for (PublicKeyInfo o : v.values) {
+                    steps.add(new SplitStep(o.kas, splitID));
+                }
+            }
+            return steps;
+        }
+
+        public BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e) throws Exception {
+            List<KeyClause> kcs = new ArrayList<>(e.must.size());
+    
+            for (SingleAttributeClause clause : e.must) {
+                List<PublicKeyInfo> kcv = new ArrayList<>(clause.values.size());
+    
+                for (AttributeValueFQN term : clause.values) {
+                    KeyAccessGrant grant = byAttribute(term);
+                    if (grant == null) {
+                        throw new Exception(String.format("no definition or grant found for [%s]", term));
+                    }
+    
+                    List<String> kases = grant.kases;
+                    if (kases.isEmpty()) {
+                        kases = List.of(RuleTypes.EMPTY_TERM);
+                    }
+    
+                    for (String kas : kases) {
+                        kcv.add(new PublicKeyInfo(kas));
+                    }
+                }
+    
+                String op = ruleToOperator(clause.def.getRule());
+                if (op == RuleTypes.UNSPECIFIED) {
+                    logger.warn("Unknown attribute rule type: " + clause);
+                }
+    
+                KeyClause kc = new KeyClause(op, kcv);
+                kcs.add(kc);
+            }
+    
+            return new BooleanKeyExpression(kcs);
+        }
+
+        AttributeBooleanExpression constructAttributeBoolean() {
+            Map<String, SingleAttributeClause> prefixes = new HashMap<>();
+            List<String> sortedPrefixes = new ArrayList<>();
+            for (AttributeValueFQN aP : policy) {
+                try {
+                    AttributeNameFQN a = aP.prefix();
+                    String p = a.toString().toLowerCase();
+                    SingleAttributeClause clause = prefixes.get(p);
+                    if (clause != null) {
+                        clause.values.add(aP);
+                    } else if (byAttribute(aP) != null) {
+                        var x = new SingleAttributeClause(byAttribute(aP).attr, Collections.singletonList(aP));
+                        prefixes.put(p, x);
+                        sortedPrefixes.add(p);
+                    }
+                } catch (AutoConfigureException e) {
+                    // Handle exception
+                }
+            }
+
+            List<SingleAttributeClause> must = sortedPrefixes.stream()
+                    .map(prefixes::get)
+                    .collect(Collectors.toList());
+
+            return new AttributeBooleanExpression(must);
+        }
+
+
+
+        public class AttributeMapping {
+
+            private Map<AttributeNameFQN, Attribute> dict;
+        
+            public AttributeMapping() {
+                this.dict = new HashMap<>();
+            }
+        
+            public void put(Attribute ad) throws Exception {
+                if (this.dict == null) {
+                    this.dict = new HashMap<>();
+                }
+        
+                AttributeNameFQN prefix;
+                try {
+                    prefix = new AttributeNameFQN(ad.getFqn());
+                } catch (Exception e) {
+                    throw new Exception(e);
+                }
+        
+                if (this.dict.containsKey(prefix)) {
+                    throw new Exception("Attribute prefix already found: [" + prefix.toString() + "]");
+                }
+        
+                this.dict.put(prefix, ad);
+            }
+        
+            public Attribute get(AttributeNameFQN prefix) throws Exception {
+                Attribute ad = this.dict.get(prefix);
+                if (ad == null) {
+                    throw new Exception("Unknown attribute type: [" + prefix.toString() + "], not in [" + this.dict.keySet().toString() + "]");
+                }
+                return ad;
+            }
+
+        }
+
+        public class SingleAttributeClause {
+
+            private Attribute def;
+            private List<AttributeValueFQN> values;
+        
+            public SingleAttributeClause(Attribute def, List<AttributeValueFQN> values) {
+                this.def = def;
+                this.values = values;
+            }
+        }
+
+        public class AttributeBooleanExpression {
+
+            private List<SingleAttributeClause> must;
+        
+            public AttributeBooleanExpression(List<SingleAttributeClause> must) {
+                this.must = must;
+            }
+        
+            @Override
+            public String toString() {
+                if (must == null || must.isEmpty()) {
+                    return "∅";
+                }
+        
+                StringBuilder sb = new StringBuilder();
+                for (int i = 0; i < must.size(); i++) {
+                    SingleAttributeClause clause = must.get(i);
+                    if (i > 0) {
+                        sb.append("&");
+                    }
+        
+                    List<AttributeValueFQN> values = clause.values;
+                    if (values == null || values.isEmpty()) {
+                        sb.append(clause.def.getFqn());
+                    } else if (values.size() == 1) {
+                        sb.append(values.get(0).toString());
+                    } else {
+                        sb.append(clause.def.getFqn());
+                        sb.append("/value/{");
+        
+                        StringJoiner joiner = new StringJoiner(",");
+                        for (AttributeValueFQN v : values) {
+                            try {
+                                joiner.add(v.value());
+                            } catch (AutoConfigureException e) {
+                                // This shouldnt happen
+                                joiner.add("");
+                            }
+                        }
+                        sb.append(joiner.toString());
+                        sb.append("}");
+                    }
+                }
+                return sb.toString();
+            }
+        
+        }
+
+        public class PublicKeyInfo {
+            private String kas;
+        
+            public PublicKeyInfo(String kas) {
+                this.kas = kas;
+            }
+        
+            public String getKas() {
+                return kas;
+            }
+        
+            public void setKas(String kas) {
+                this.kas = kas;
+            }
+        }
+
+        public class KeyClause {
+            private String operator;
+            private List<PublicKeyInfo> values;
+        
+            public KeyClause(String operator, List<PublicKeyInfo> values) {
+                this.operator = operator;
+                this.values = values;
+            }
+        
+            @Override
+            public String toString() {
+                if (values.size() == 1 && values.get(0).getKas().equals(RuleTypes.EMPTY_TERM)) {
+                    return "[" + RuleTypes.EMPTY_TERM + "]";
+                }
+                if (values.size() == 1) {
+                    return "(" + values.get(0).getKas() + ")";
+                }
+        
+                StringBuilder sb = new StringBuilder();
+                sb.append("(");
+                String op = "⋀";
+                if (operator.equals(RuleTypes.ANY_OF)) {
+                    op = "⋁";
+                }
+        
+                for (int i = 0; i < values.size(); i++) {
+                    if (i > 0) {
+                        sb.append(op);
+                    }
+                    sb.append(values.get(i).getKas());
+                }
+                sb.append(")");
+        
+                return sb.toString();
+            }
+        }
+
+        public class BooleanKeyExpression {
+            private List<KeyClause> values;
+        
+            public BooleanKeyExpression(List<KeyClause> values) {
+                this.values = values;
+            }
+        
+            @Override
+            public String toString() {
+                StringBuilder sb = new StringBuilder();
+                for (int i = 0; i < values.size(); i++) {
+                    if (i > 0) {
+                        sb.append("&");
+                    }
+                    sb.append(values.get(i).toString());
+                }
+                return sb.toString();
+            }
+
+            public int len() {
+                int count = 0;
+                for (KeyClause v : values) {
+                    count += v.values.size();
+                }
+                return count;
+            }
+
+            public BooleanKeyExpression reduce() {
+                List<Disjunction> conjunction = new ArrayList<>();
+                for (KeyClause v : values) {
+                    if (v.operator.equals("anyOf")) {  // Assuming "anyOf" is a constant in Java
+                        Disjunction terms = sortedNoDupes(v.values);
+                        if (!terms.isEmpty() && !within(conjunction, terms)) {
+                            conjunction.add(terms);
+                        }
+                    } else {
+                        for (PublicKeyInfo k : v.values) {
+                            if (k.getKas().equals("emptyTerm")) {  // Assuming "emptyTerm" is a constant in Java
+                                continue;
+                            }
+                            Disjunction terms = (Disjunction) List.of(k.getKas());
+                            if (!within(conjunction, terms)) {
+                                conjunction.add(terms);
+                            }
+                        }
+                    }
+                }
+                if (conjunction.isEmpty()) {
+                    return new BooleanKeyExpression(new ArrayList<>());
+                }
+        
+                List<KeyClause> newValues = new ArrayList<>();
+                for (List<String> d : conjunction) {
+                    List<PublicKeyInfo> pki = new ArrayList<>();
+                    for (String k : d) {
+                        pki.add(new PublicKeyInfo(k));
+                    }
+                    newValues.add(new KeyClause("anyOf", pki));  // Assuming "anyOf" is a constant in Java
+                }
+                return new BooleanKeyExpression(newValues);
+            }
+
+
+        }
+
+        class Disjunction extends ArrayList<String> {
+
+            public boolean less(Disjunction r) {
+                int m = Math.min(this.size(), r.size());
+                for (int i = 0; i < m; i++) {
+                    int comparison = this.get(i).compareTo(r.get(i));
+                    if (comparison < 0) {
+                        return true;
+                    }
+                    if (comparison > 0) {
+                        return false;
+                    }
+                }
+                return this.size() < r.size();
+            }
+        
+            public boolean equals(Object obj) {
+                if (this == obj) {
+                    return true;
+                }
+                if (obj == null || getClass() != obj.getClass()) {
+                    return false;
+                }
+                Disjunction r = (Disjunction) obj;
+                if (this.size() != r.size()) {
+                    return false;
+                }
+                for (int i = 0; i < this.size(); i++) {
+                    if (!this.get(i).equals(r.get(i))) {
+                        return false;
+                    }
+                }
+                return true;
+            }
+        
+            public boolean within(List<Disjunction> list, Disjunction e) {
+                for (Disjunction v : list) {
+                    if (e.equals(v)) {
+                        return true;
+                    }
+                }
+                return false;
+            }
+        }
+
+        public static boolean within(List<Disjunction> list, Disjunction e) {
+            for (Disjunction v : list) {
+                if (e.equals(v)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        public static Disjunction sortedNoDupes(List<PublicKeyInfo> l) {
+            Set<String> set = new HashSet<>();
+            List<String> list = new ArrayList<>();
+
+            for (PublicKeyInfo e : l) {
+                String kas = e.getKas();
+                if (!kas.equals(RuleTypes.EMPTY_TERM) && !set.contains(kas)) {
+                    set.add(kas);
+                    list.add(kas);
+                }
+            }
+
+            Collections.sort(list);
+            return (Disjunction) list;
+        }
+
+        public static String ruleToOperator(AttributeRuleTypeEnum e) {
+            switch (e) {
+                case ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF:
+                    return "allOf";
+                case ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF:
+                    return "anyOf";
+                case ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY:
+                    return "hierarchy";
+                case ATTRIBUTE_RULE_TYPE_ENUM_UNSPECIFIED:
+                    return "unspecified";
+                default:
+                    return "";
+            }
+        }
+
+    }
+
+    // Gets a list of directory of KAS grants for a list of attribute FQNs
+    public static Granter newGranterFromService(SDK.AttributesService as, AttributeValueFQN... fqns) throws Exception {
+        String[] fqnsStr = new String[fqns.length];
+        for (int i = 0; i < fqns.length; i++) {
+            fqnsStr[i] = fqns[i].toString();
+        }
+
+        GetAttributeValuesByFqnsRequest request = GetAttributeValuesByFqnsRequest.newBuilder()
+            .addAllFqns(Arrays.asList(fqnsStr))
+            .setWithValue(AttributeValueSelector.newBuilder().setWithKeyAccessGrants(true).build())
+            .build();
+
+        GetAttributeValuesByFqnsResponse av;
+        av = as.getAttributeValuesByFqn(request);
+
+        Granter grants = new Granter(Arrays.asList(fqns), new HashMap<>());
+
+        for (Map.Entry<String,GetAttributeValuesByFqnsResponse.AttributeAndValue> entry : av.getFqnAttributeValuesMap().entrySet()) {
+            String fqnstr = entry.getKey();
+            AttributeAndValue pair = entry.getValue();
+
+            AttributeValueFQN fqn;
+            try {
+                fqn = new AttributeValueFQN(fqnstr);
+            } catch (Exception e) {
+                return grants;
+            }
+
+            Attribute def = pair.getAttribute();
+            if (def != null) {
+                grants.addAllGrants(fqn, def.getGrantsList(), def);
+            }
+
+            Value v = pair.getValue();
+            if (v != null) {
+                grants.addAllGrants(fqn, v.getGrantsList(), def);
+            }
+        }
+
+        return grants;
+    }
+
+    // Given a policy (list of data attributes or tags),
+    // get a set of grants from attribute values to KASes.
+    // Unlike `NewGranterFromService`, this works offline.
+    public static Granter newGranterFromAttributes(Value... attrs) throws Exception {
+        List<AttributeValueFQN> policyList = new ArrayList<>(attrs.length);
+        Map<String, KeyAccessGrant> grantsMap = new HashMap<>();
+
+        Granter grants = new Granter(policyList, grantsMap);
+
+        for (Value v : attrs) {
+            AttributeValueFQN fqn;
+            try {
+                fqn = new AttributeValueFQN(v.getFqn());
+            } catch (Exception e) {
+                return grants;
+            }
+
+            grants.policy.add(fqn);
+            Attribute def = v.getAttribute();
+            if (def == null) {
+                throw new Exception("No associated definition with value [" + fqn.toString() + "]");
+            }
+
+            grants.addAllGrants(fqn, def.getGrantsList(), def);
+            grants.addAllGrants(fqn, v.getGrantsList(), def);
+        }
+
+        return grants;
+    }
+
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
index cc276196..f436dfeb 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
@@ -53,16 +53,6 @@ public AssertionConfig() {
         }
     }
 
-    public static class SplitStep {
-        public String kas;
-        public String splitID;
-
-        public SplitStep(String kas, String sid) {
-            this.kas = kas;
-            this.splitID = sid;
-          }
-    }
-
     public static class TDFConfig {
         public int defaultSegmentSize;
         public boolean enableEncryption;
@@ -77,7 +67,7 @@ public static class TDFConfig {
         public List<Assertion> assertionList;
         public AssertionConfig assertionConfig;
         public String mimeType;
-        public List<SplitStep> splitPlan;
+        public List<Autoconfigure.SplitStep> splitPlan;
 
         public TDFConfig() {
             this.defaultSegmentSize = DEFAULT_SEGMENT_SIZE;
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index 1beb902f..2e033e3a 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -181,9 +181,9 @@ private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
             Map<String, Config.KASInfo> latestKASInfo = new HashMap<>();
             if (tdfConfig.splitPlan.isEmpty()) {
                 // Default split plan: Split keys across all KASes
-                List<Config.SplitStep> splitPlan = new ArrayList<>(tdfConfig.kasInfoList.size());
+                List<Autoconfigure.SplitStep> splitPlan = new ArrayList<>(tdfConfig.kasInfoList.size());
                 for (Config.KASInfo kasInfo : tdfConfig.kasInfoList) {
-                    Config.SplitStep step = new Config.SplitStep(kasInfo.URL, "");
+                    Autoconfigure.SplitStep step = new Autoconfigure.SplitStep(kasInfo.URL, "");
                     if (tdfConfig.kasInfoList.size() > 1) {
                         step.splitID = String.format("s-%d", splitPlan.size());
                     }
@@ -206,7 +206,7 @@ private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
             Map<String, List<Config.KASInfo>> conjunction = new HashMap<>();
             List<String> splitIDs = new ArrayList<>();
 
-            for (Config.SplitStep splitInfo : tdfConfig.splitPlan) {
+            for (Autoconfigure.SplitStep splitInfo : tdfConfig.splitPlan) {
                 // Public key was passed in with kasInfoList
                 // TODO First look up in attribute information / add to split plan?
                 Config.KASInfo ki = latestKASInfo.get(splitInfo.kas);
@@ -553,7 +553,7 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
         
         Set<String> knownSplits = new HashSet<String>();
         Set<String> foundSplits = new HashSet<String>();;
-        Map<Config.SplitStep, Exception> skippedSplits = new HashMap<>();
+        Map<Autoconfigure.SplitStep, Exception> skippedSplits = new HashMap<>();
         boolean mixedSplits = manifest.encryptionInformation.keyAccessObj.size() > 1 &&
          (manifest.encryptionInformation.keyAccessObj.get(0).sid != null) &&
           !manifest.encryptionInformation.keyAccessObj.get(0).sid.isEmpty();
@@ -562,7 +562,7 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
 
         if (manifest.payload.isEncrypted) {
             for (Manifest.KeyAccess keyAccess: manifest.encryptionInformation.keyAccessObj) {
-                Config.SplitStep ss = new Config.SplitStep(keyAccess.url, keyAccess.sid);
+                Autoconfigure.SplitStep ss = new Autoconfigure.SplitStep(keyAccess.url, keyAccess.sid);
                 byte[] unwrappedKey;
                 if (!mixedSplits) {
                     unwrappedKey = kas.unwrap(keyAccess, manifest.encryptionInformation.policy);
@@ -605,7 +605,7 @@ public Reader loadTDF(SeekableByteChannel tdf, Config.AssertionConfig assertionC
                 List<Exception> exceptionList = new ArrayList<>(skippedSplits.size() + 1);
                 exceptionList.add(new Exception("splitKey.unable to reconstruct split key: " + skippedSplits));
                 
-                for (Map.Entry<Config.SplitStep, Exception> entry : skippedSplits.entrySet()) {
+                for (Map.Entry<Autoconfigure.SplitStep, Exception> entry : skippedSplits.entrySet()) {
                     exceptionList.add(entry.getValue());
                 }
                 
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
new file mode 100644
index 00000000..25a7748a
--- /dev/null
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
@@ -0,0 +1,555 @@
+package io.opentdf.platform.sdk;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+import io.opentdf.platform.policy.Attribute;
+import io.opentdf.platform.policy.Value;
+import io.opentdf.platform.sdk.Autoconfigure.AttributeValueFQN;
+import io.opentdf.platform.sdk.Autoconfigure.Granter.AttributeBooleanExpression;
+import io.opentdf.platform.sdk.Autoconfigure.Granter.BooleanKeyExpression;
+import io.opentdf.platform.sdk.Autoconfigure.Granter;
+import io.opentdf.platform.sdk.Autoconfigure.SplitStep;
+import io.opentdf.platform.policy.Namespace;
+import io.opentdf.platform.policy.KeyAccessServer;
+import io.opentdf.platform.policy.AttributeRuleTypeEnum;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class AutoconfigureTest {
+
+    private static final String KAS_AU = "http://kas.au/";
+    private static final String KAS_CA = "http://kas.ca/";
+    private static final String KAS_UK = "http://kas.uk/";
+    private static final String KAS_NZ = "http://kas.nz/";
+    private static final String KAS_US = "http://kas.us/";
+    private static final String KAS_US_HCS = "http://hcs.kas.us/";
+    private static final String KAS_US_SA = "http://si.kas.us/";
+    private static final String AUTHORITY = "https://virtru.com/";
+
+    private static Autoconfigure.AttributeNameFQN CLS;
+    private static Autoconfigure.AttributeNameFQN N2K;
+    private static Autoconfigure.AttributeNameFQN REL;
+
+    private static Autoconfigure.AttributeValueFQN clsA;
+    private static Autoconfigure.AttributeValueFQN clsS;
+    private static Autoconfigure.AttributeValueFQN clsTS;
+    private static Autoconfigure.AttributeValueFQN n2kHCS;
+    private static Autoconfigure.AttributeValueFQN n2kInt;
+    private static Autoconfigure.AttributeValueFQN n2kSI;
+    private static Autoconfigure.AttributeValueFQN rel2can;
+    private static Autoconfigure.AttributeValueFQN rel2gbr;
+    private static Autoconfigure.AttributeValueFQN rel2nzl;
+    private static Autoconfigure.AttributeValueFQN rel2usa;
+
+    @BeforeAll
+    public static void setup() throws AutoConfigureException {
+        // Initialize the FQNs (Fully Qualified Names)
+        CLS = new Autoconfigure.AttributeNameFQN("https://virtru.com/attr/Classification");
+        N2K = new Autoconfigure.AttributeNameFQN("https://virtru.com/attr/Need%20to%20Know");
+        REL = new Autoconfigure.AttributeNameFQN("https://virtru.com/attr/Releasable%20To");
+
+        clsA = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Classification/value/Allowed");
+        clsS = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Classification/value/Secret");
+        clsTS = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Classification/value/Top%20Secret");
+
+        n2kHCS = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Need%20to%20Know/value/HCS");
+        n2kInt = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Need%20to%20Know/value/INT");
+        n2kSI = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Need%20to%20Know/value/SI");
+
+        rel2can = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Releasable%20To/value/CAN");
+        rel2gbr = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Releasable%20To/value/GBR");
+        rel2nzl = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Releasable%20To/value/NZL");
+        rel2usa = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Releasable%20To/value/USA");
+    }
+
+    private String spongeCase(String s) {
+        String regex = "^(https?://[\\w./]+/attr/)([^/]*)(/value/)?(\\S*)?$";
+        if (s.matches(regex)) {
+            String[] parts = s.split(regex);
+            String prefix = parts[1];
+            String main = parts[2];
+            String suffix = parts[3] != null ? parts[3] : "";
+            String value = parts[4] != null ? parts[4] : "";
+
+            StringBuilder sb = new StringBuilder();
+            sb.append(prefix);
+            for (int i = 0; i < main.length(); i++) {
+                sb.append(i % 2 == 0 ? Character.toLowerCase(main.charAt(i)) : Character.toUpperCase(main.charAt(i)));
+            }
+            sb.append(suffix);
+            for (int i = 0; i < value.length(); i++) {
+                sb.append(i % 2 == 0 ? Character.toLowerCase(value.charAt(i)) : Character.toUpperCase(value.charAt(i)));
+            }
+            return sb.toString();
+        } else {
+            throw new IllegalArgumentException("Invalid URL format");
+        }
+    }
+
+    private List<Value> valuesToPolicy(AttributeValueFQN... p)  {
+        return List.of(p).stream()
+            .map(t -> {
+                try {
+                    return mockValueFor(t);
+                } catch (AutoConfigureException e) {
+                    // TODO Auto-generated catch block
+                    e.printStackTrace();
+                    return Value.newBuilder().build();
+                }
+            })
+            .collect(Collectors.toList());
+    }
+
+    private List<String> policyToStringKeys(List<AttributeValueFQN> policy) {
+        return policy.stream()
+            .map(AttributeValueFQN::getKey)
+            .collect(Collectors.toList());
+    }
+
+    private Autoconfigure.AttributeValueFQN messUpV(Autoconfigure.AttributeValueFQN a) {
+        try {
+            return new Autoconfigure.AttributeValueFQN(spongeCase(a.toString()));
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to create AttributeValueFQN", e);
+        }
+    }
+
+    private Attribute mockAttributeFor(Autoconfigure.AttributeNameFQN fqn) {
+        String key = fqn.getKey();
+        switch (key) {
+            case "CLS":
+                return Attribute.newBuilder().setId("CLS").setNamespace(
+                    Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
+                    .setName("Classification").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY).setFqn(fqn.toString()).build();
+            case "N2K":
+                return Attribute.newBuilder().setId("N2K").setNamespace(
+                    Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
+                    .setName("Need to Know").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF).setFqn(fqn.toString()).build();
+            case "REL":
+            return Attribute.newBuilder().setId("REL").setNamespace(
+                    Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
+                    .setName("Releasable To").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF).setFqn(fqn.toString()).build();
+            default:
+                return null;
+        }
+    }
+
+    private Value mockValueFor(Autoconfigure.AttributeValueFQN fqn) throws AutoConfigureException {
+        Autoconfigure.AttributeNameFQN an = fqn.prefix();
+        Attribute a = mockAttributeFor(an);
+        String v = fqn.value();
+        Value p = Value.newBuilder()
+        .setId(a.getId() + ":" + v)
+        .setAttribute(a)
+        .setValue(v)
+        .setFqn(fqn.toString())
+        .build();
+
+        switch (an.getKey()) {
+            case "N2K":
+                switch (v.toUpperCase()) {
+                    case "INT":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_UK).build()).build();
+                        break;
+                    case "HCS":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_US_HCS).build()).build();
+                        break;
+                    case "SI":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_US_SA).build()).build();
+                        break;
+                }
+                break;
+
+            case "REL":
+                switch (v.toUpperCase()) {
+                    case "FVEY":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_AU).build())
+                        .addGrants(1, KeyAccessServer.newBuilder().setUri(KAS_CA).build())
+                        .addGrants(1, KeyAccessServer.newBuilder().setUri(KAS_UK).build())
+                        .addGrants(1, KeyAccessServer.newBuilder().setUri(KAS_NZ).build())
+                        .addGrants(1, KeyAccessServer.newBuilder().setUri(KAS_US).build())
+                        .build();
+                        break;
+                    case "AUS":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_AU).build())
+                        .build();
+                        break;
+                    case "CAN":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_CA).build())
+                        .build();
+                        break;
+                    case "GBR":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_UK).build())
+                        .build();
+                        break;
+                    case "NZL":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_NZ).build())
+                        .build();
+                        break;
+                    case "USA":
+                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_US).build())
+                        .build();
+                        break;
+                }
+                break;
+
+            case "CLS":
+                // defaults only
+                break;
+        }
+        return p;
+    }
+
+    @Test
+    public void testAttributeFromURL() throws AutoConfigureException {
+        for (TestCase tc : List.of(
+            new TestCase("letter", "http://e/attr/a", "http://e", "a"),
+            new TestCase("number", "http://e/attr/1", "http://e", "1"),
+            new TestCase("emoji", "http://e/attr/%F0%9F%98%81", "http://e", "😁"),
+            new TestCase("dash", "http://a-b.com/attr/b-c", "http://a-b.com", "b-c")
+        )) {
+            Autoconfigure.AttributeNameFQN a = new Autoconfigure.AttributeNameFQN(tc.getU());
+            assertThat(a.authority()).isEqualTo(tc.getAuth());
+            assertThat(a.name()).isEqualTo(tc.getName());
+        }
+    }
+
+    @Test
+    public void testAttributeFromMalformedURL() {
+        for (TestCase tc : List.of(
+            new TestCase("no name", "http://e/attr"),
+            new TestCase("invalid prefix 1", "hxxp://e/attr/a"),
+            new TestCase("invalid prefix 2", "e/attr/a"),
+            new TestCase("invalid prefix 3", "file://e/attr/a"),
+            new TestCase("invalid prefix 4", "http:///attr/a"),
+            new TestCase("bad encoding", "https://a/attr/%😁"),
+            new TestCase("with value", "http://e/attr/a/value/b")
+        )) {
+            assertThatThrownBy(() -> new Autoconfigure.AttributeNameFQN(tc.getU()))
+                .isInstanceOf(IllegalArgumentException.class);
+        }
+    }
+
+    @Test
+    public void testAttributeValueFromURL() {
+        List<TestCase> testCases = List.of(
+            new TestCase("number", "http://e/attr/a/value/1", "http://e", "a", "1"),
+            new TestCase("space", "http://e/attr/a/value/%20", "http://e", "a", " "),
+            new TestCase("emoji", "http://e/attr/a/value/%F0%9F%98%81", "http://e", "a", "😁"),
+            new TestCase("numberdef", "http://e/attr/1/value/one", "http://e", "1", "one"),
+            new TestCase("valuevalue", "http://e/attr/value/value/one", "http://e", "value", "one"),
+            new TestCase("dash", "http://a-b.com/attr/b-c/value/c-d", "http://a-b.com", "b-c", "c-d")
+        );
+
+        for (TestCase tc : testCases) {
+            assertDoesNotThrow(() -> {
+                AttributeValueFQN a = new AttributeValueFQN(tc.getU());
+                assertThat(a.authority()).isEqualTo(tc.getAuth());
+                assertThat(a.name()).isEqualTo(tc.getName());
+                assertThat(a.value()).isEqualTo(tc.getValue());
+            });
+        }
+    }
+
+    @Test
+    public void testAttributeValueFromMalformedURL() {
+        List<TestCase> testCases = List.of(
+            new TestCase("no name", "http://e/attr/value/1"),
+            new TestCase("no value", "http://e/attr/who/value"),
+            new TestCase("invalid prefix 1", "hxxp://e/attr/a/value/1"),
+            new TestCase("invalid prefix 2", "e/attr/a/a/value/1"),
+            new TestCase("bad encoding", "https://a/attr/emoji/value/%😁")
+        );
+
+        for (TestCase tc : testCases) {
+            assertThatThrownBy(() -> new AttributeValueFQN(tc.getU()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Invalid URL format");
+        }
+    }
+
+    @Test
+    public void testConfigurationServicePutGet() {
+        List<ConfigurationTestCase> testCases = List.of(
+            new ConfigurationTestCase("default", List.of(clsA), 1, List.of()),
+            new ConfigurationTestCase("one-country", List.of(rel2gbr), 1, List.of(KAS_UK)),
+            new ConfigurationTestCase("two-country", List.of(rel2gbr, rel2nzl), 2, List.of(KAS_UK, KAS_NZ)),
+            new ConfigurationTestCase("with-default", List.of(clsA, rel2gbr), 2, List.of(KAS_UK)),
+            new ConfigurationTestCase("need-to-know", List.of(clsTS, rel2usa, n2kSI), 3, List.of(KAS_US, KAS_US_SA))
+        );
+
+        for (ConfigurationTestCase tc : testCases) {
+            assertDoesNotThrow(() -> {
+                List<Value> v = valuesToPolicy(tc.getPolicy().toArray(new AttributeValueFQN[0]));
+                Granter grants = Autoconfigure.newGranterFromAttributes(v.toArray(new Value[0]));
+                assertThat(grants).isNotNull();
+                assertThat(grants.getGrants()).hasSize(tc.getSize());
+                assertThat(policyToStringKeys(tc.getPolicy())).containsAll(grants.getGrants().keySet());
+                Set<String> actualKases = Collections.emptySet();  ;
+                for (Autoconfigure.KeyAccessGrant g : grants.getGrants().values()) {
+                    assertThat(g).isNotNull();
+                    for (String k : g.kases){
+                        actualKases.add(k);
+                    }
+                }
+
+                String[] kasArray = tc.getKases().toArray(new String[tc.getKases().size()]);
+                assertThat(actualKases).containsExactlyInAnyOrder(kasArray);
+            }
+            );
+        }
+    }
+
+    @Test
+    public void testReasonerConstructAttributeBoolean() {
+        List<ReasonerTestCase> testCases = List.of(
+            new ReasonerTestCase(
+                "one actual with default",
+                List.of(clsS, rel2can),
+                List.of(KAS_US),
+                "https://virtru.com/attr/Classification/value/Secret&https://virtru.com/attr/Releasable%20To/value/CAN",
+                "[DEFAULT]&(http://kas.ca/)",
+                "(http://kas.ca/)",
+                List.of(new SplitStep(KAS_CA, ""))
+            ),
+            new ReasonerTestCase(
+                "one defaulted attr",
+                List.of(clsS),
+                List.of(KAS_US),
+                "https://virtru.com/attr/Classification/value/Secret",
+                "[DEFAULT]",
+                "",
+                List.of(new SplitStep(KAS_US, ""))
+            ),
+            new ReasonerTestCase(
+                "empty policy",
+                List.of(),
+                List.of(KAS_US),
+                "∅",
+                "",
+                "",
+                List.of(new SplitStep(KAS_US, ""))
+            ),
+            new ReasonerTestCase(
+                "old school splits",
+                List.of(),
+                List.of(KAS_AU, KAS_CA, KAS_US),
+                "∅",
+                "",
+                "",
+                List.of(new SplitStep(KAS_AU, "1"), new SplitStep(KAS_CA, "2"), new SplitStep(KAS_US, "3"))
+            ),
+            new ReasonerTestCase(
+                "simple with all three ops",
+                List.of(clsS, rel2gbr, n2kInt),
+                List.of(KAS_US),
+                "https://virtru.com/attr/Classification/value/Secret&https://virtru.com/attr/Releasable%20To/value/GBR&https://virtru.com/attr/Need%20to%20Know/value/INT",
+                "[DEFAULT]&(http://kas.uk/)&(http://kas.uk/)",
+                "(http://kas.uk/)",
+                List.of(new SplitStep(KAS_UK, ""))
+            ),
+            new ReasonerTestCase(
+                "compartments",
+                List.of(clsS, rel2gbr, rel2usa, n2kHCS, n2kSI),
+                List.of(KAS_US),
+                "https://virtru.com/attr/Classification/value/Secret&https://virtru.com/attr/Releasable%20To/value/{GBR,USA}&https://virtru.com/attr/Need%20to%20Know/value/{HCS,SI}",
+                "[DEFAULT]&(http://kas.uk/⋁http://kas.us/)&(http://hcs.kas.us/⋀http://si.kas.us/)",
+                "(http://kas.uk/⋁http://kas.us/)&(http://hcs.kas.us/)&(http://si.kas.us/)",
+                List.of(new SplitStep(KAS_UK, "1"), new SplitStep(KAS_US, "1"), new SplitStep(KAS_US_HCS, "2"), new SplitStep(KAS_US_SA, "3"))
+            ),
+            new ReasonerTestCase(
+                "compartments - case insensitive",
+                List.of(
+                    messUpV(clsS), messUpV(rel2gbr), messUpV(rel2usa), messUpV(n2kHCS), messUpV(n2kSI)
+                ),
+                List.of(KAS_US),
+                "https://virtru.com/attr/Classification/value/Secret&https://virtru.com/attr/Releasable%20To/value/{GBR,USA}&https://virtru.com/attr/Need%20to%20Know/value/{HCS,SI}",
+                "[DEFAULT]&(http://kas.uk/⋁http://kas.us/)&(http://hcs.kas.us/⋀http://si.kas.us/)",
+                "(http://kas.uk/⋁http://kas.us/)&(http://hcs.kas.us/)&(http://si.kas.us/)",
+                List.of(new SplitStep(KAS_UK, "1"), new SplitStep(KAS_US, "1"), new SplitStep(KAS_US_HCS, "2"), new SplitStep(KAS_US_SA, "3"))
+            )
+        );
+
+        for (ReasonerTestCase tc : testCases) {
+            assertDoesNotThrow(() -> {
+                Granter reasoner = Autoconfigure.newGranterFromAttributes(valuesToPolicy(tc.getPolicy().toArray(new AttributeValueFQN[0])).toArray(new Value[0]));
+                assertThat(reasoner).isNotNull();
+
+                AttributeBooleanExpression actualAB = reasoner.constructAttributeBoolean();
+                assertThat(actualAB.toString().toLowerCase()).isEqualTo(tc.getAts().toLowerCase());
+
+                BooleanKeyExpression actualKeyed = reasoner.insertKeysForAttribute(actualAB);
+                assertThat(actualKeyed).isEqualTo(tc.getKeyed());
+
+                String reduced = actualKeyed.reduce().toString();
+                assertThat(reduced).isEqualTo(tc.getReduced());
+
+                int i = 0;
+                List<SplitStep> plan = reasoner.plan(tc.getDefaults(), () -> String.valueOf(i + 1));
+                assertThat(plan).isEqualTo(tc.getPlan());
+            }
+            );
+        }
+    }
+
+    // @Test
+    // public void testSpongeCase() {
+    //     for (TestCase tc : List.of(
+    //         new TestCase("attr", "http://a/attr/b", "http://a/attr/b"),
+    //         new TestCase("value", "http://a/attr/b/value/c", "http://a/attr/b/Value/C"),
+    //         new TestCase("with value", "http://a/attr/b/value/c/d", "http://a/attr/b/Value/C/D")
+    //     )) {
+    //         String s = spongeCase(tc.getU());
+    //         assertThat(s).isEqualTo(tc.getExpected());
+    //     }
+    // }
+
+    // @Test
+    // public void testMessUp() throws AutoConfigureException {
+    //     for (TestCase tc : List.of(
+    //         new TestCase("n2k", n2kInt, "https://virtru.com/attr/need%20to%20know/value/iNt"),
+    //         new TestCase("cls", clsS, "https://virtru.com/attr/Classification/value/sEcReT"),
+    //         new TestCase("rel", rel2usa, "https://virtru.com/attr/Releasable%20To/value/uSa")
+    //     )) {
+    //         Autoconfigure.AttributeValueFQN v = messUpV(tc.getA());
+    //         assertThat(v.toString()).isEqualTo(tc.getExpected());
+    //     }
+    // }
+
+    private static class TestCase {
+        private final String n;
+        private final String u;
+        private final String auth;
+        private final String name;
+        private final String value;
+
+
+        TestCase(String n, String u, String auth, String name, String value) {
+            this.n = n;
+            this.u = u;
+            this.auth = auth;
+            this.name = name;
+            this.value = value;
+        }
+
+        TestCase(String n, String u, String auth, String name) {
+            this(n, u, auth, name, null);
+        }
+
+        TestCase(String n, String u) {
+            this(n, u, null, null, null);
+        }
+
+        TestCase(String n, Autoconfigure.AttributeValueFQN a) {
+            this(n, a.toString(), null, null, null);
+        }
+
+        public String getU() {
+            return u;
+        }
+
+        public String getAuth() {
+            return auth;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public String getValue() {
+            return value;
+        }
+
+        public Autoconfigure.AttributeValueFQN getA() throws AutoConfigureException {
+            return new Autoconfigure.AttributeValueFQN(u);
+        }
+    }
+
+    private static class ConfigurationTestCase {
+        private final String name;
+        private final List<AttributeValueFQN> policy;
+        private final int size;
+        private final List<String> kases;
+
+        ConfigurationTestCase(String name, List<AttributeValueFQN> policy, int size, List<String> kases) {
+            this.name = name;
+            this.policy = policy;
+            this.size = size;
+            this.kases = kases;
+        }
+
+        public String getN() {
+            return name;
+        }
+
+        public List<AttributeValueFQN> getPolicy() {
+            return policy;
+        }
+
+        public int getSize() {
+            return size;
+        }
+
+        public List<String> getKases() {
+            return kases;
+        }
+    }
+
+    private static class ReasonerTestCase {
+        private final String name;
+        private final List<AttributeValueFQN> policy;
+        private final List<String> defaults;
+        private final String ats;
+        private final String keyed;
+        private final String reduced;
+        private final List<SplitStep> plan;
+
+        ReasonerTestCase(String name, List<AttributeValueFQN> policy, List<String> defaults, String ats, String keyed, String reduced, List<SplitStep> plan) {
+            this.name = name;
+            this.policy = policy;
+            this.defaults = defaults;
+            this.ats = ats;
+            this.keyed = keyed;
+            this.reduced = reduced;
+            this.plan = plan;
+        }
+
+        public String getN() {
+            return name;
+        }
+
+        public List<AttributeValueFQN> getPolicy() {
+            return policy;
+        }
+
+        public List<String> getDefaults() {
+            return defaults;
+        }
+
+        public String getAts() {
+            return ats;
+        }
+
+        public String getKeyed() {
+            return keyed;
+        }
+
+        public String getReduced() {
+            return reduced;
+        }
+
+        public List<SplitStep> getPlan() {
+            return plan;
+        }
+    }
+}

From 3610633beb7614f21235a7b1b94dfd5787df22bd Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Mon, 19 Aug 2024 16:21:41 -0400
Subject: [PATCH 12/23] most tests working

---
 .../opentdf/platform/sdk/Autoconfigure.java   |  16 +-
 .../platform/sdk/AutoconfigureTest.java       | 157 +++++++++---------
 2 files changed, 79 insertions(+), 94 deletions(-)

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index b6a8e928..ebe7b4aa 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -545,7 +545,8 @@ public BooleanKeyExpression reduce() {
                             if (k.getKas().equals("emptyTerm")) {  // Assuming "emptyTerm" is a constant in Java
                                 continue;
                             }
-                            Disjunction terms = (Disjunction) List.of(k.getKas());
+                            Disjunction terms = new Disjunction();
+                            terms.add(k.getKas());
                             if (!within(conjunction, terms)) {
                                 conjunction.add(terms);
                             }
@@ -604,15 +605,6 @@ public boolean equals(Object obj) {
                 }
                 return true;
             }
-        
-            public boolean within(List<Disjunction> list, Disjunction e) {
-                for (Disjunction v : list) {
-                    if (e.equals(v)) {
-                        return true;
-                    }
-                }
-                return false;
-            }
         }
 
         public static boolean within(List<Disjunction> list, Disjunction e) {
@@ -626,7 +618,7 @@ public static boolean within(List<Disjunction> list, Disjunction e) {
 
         public static Disjunction sortedNoDupes(List<PublicKeyInfo> l) {
             Set<String> set = new HashSet<>();
-            List<String> list = new ArrayList<>();
+            Disjunction list = new Disjunction();
 
             for (PublicKeyInfo e : l) {
                 String kas = e.getKas();
@@ -637,7 +629,7 @@ public static Disjunction sortedNoDupes(List<PublicKeyInfo> l) {
             }
 
             Collections.sort(list);
-            return (Disjunction) list;
+            return list;
         }
 
         public static String ruleToOperator(AttributeRuleTypeEnum e) {
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
index 25a7748a..8a814700 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
@@ -20,10 +20,13 @@
 import org.junit.jupiter.api.Test;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 public class AutoconfigureTest {
 
@@ -72,31 +75,43 @@ public static void setup() throws AutoConfigureException {
         rel2usa = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Releasable%20To/value/USA");
     }
 
-    private String spongeCase(String s) {
-        String regex = "^(https?://[\\w./]+/attr/)([^/]*)(/value/)?(\\S*)?$";
-        if (s.matches(regex)) {
-            String[] parts = s.split(regex);
-            String prefix = parts[1];
-            String main = parts[2];
-            String suffix = parts[3] != null ? parts[3] : "";
-            String value = parts[4] != null ? parts[4] : "";
-
-            StringBuilder sb = new StringBuilder();
-            sb.append(prefix);
-            for (int i = 0; i < main.length(); i++) {
-                sb.append(i % 2 == 0 ? Character.toLowerCase(main.charAt(i)) : Character.toUpperCase(main.charAt(i)));
+    private static String spongeCase(String s) {
+        final Pattern PATTERN = Pattern.compile("^(https?://[\\w./]+/attr/)([^/]*)(/value/)?(\\S*)?$");
+        Matcher matcher = PATTERN.matcher(s);
+
+        if (!matcher.matches()) {
+            throw new IllegalArgumentException("Invalid input string");
+        }
+
+        StringBuilder sb = new StringBuilder();
+        sb.append(matcher.group(1));
+
+        String n = matcher.group(2);
+        for (int i = 0; i < n.length(); i++) {
+            String sub = n.substring(i, i + 1);
+            if ((i & 1) == 1) {
+                sb.append(sub.toUpperCase());
+            } else {
+                sb.append(sub);
             }
-            sb.append(suffix);
-            for (int i = 0; i < value.length(); i++) {
-                sb.append(i % 2 == 0 ? Character.toLowerCase(value.charAt(i)) : Character.toUpperCase(value.charAt(i)));
+        }
+
+        if (matcher.group(3) != null) {
+            sb.append(matcher.group(3));
+            String v = matcher.group(4);
+            for (int i = 0; i < v.length(); i++) {
+                String sub = v.substring(i, i + 1);
+                if ((i & 1) == 1) {
+                    sb.append(sub);
+                } else {
+                    sb.append(sub.toUpperCase());
+                }
             }
-            return sb.toString();
-        } else {
-            throw new IllegalArgumentException("Invalid URL format");
         }
+        return sb.toString();
     }
 
-    private List<Value> valuesToPolicy(AttributeValueFQN... p)  {
+    private List<Value> valuesToPolicy(AttributeValueFQN... p) throws AutoConfigureException {
         return List.of(p).stream()
             .map(t -> {
                 try {
@@ -126,27 +141,31 @@ private Autoconfigure.AttributeValueFQN messUpV(Autoconfigure.AttributeValueFQN
 
     private Attribute mockAttributeFor(Autoconfigure.AttributeNameFQN fqn) {
         String key = fqn.getKey();
-        switch (key) {
-            case "CLS":
+        if (key.equals(CLS.getKey())){
                 return Attribute.newBuilder().setId("CLS").setNamespace(
                     Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
                     .setName("Classification").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY).setFqn(fqn.toString()).build();
-            case "N2K":
-                return Attribute.newBuilder().setId("N2K").setNamespace(
+        }
+        else if (key.equals(N2K.getKey())) {
+            return Attribute.newBuilder().setId("N2K").setNamespace(
                     Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
                     .setName("Need to Know").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF).setFqn(fqn.toString()).build();
-            case "REL":
+        }
+        else if (key.equals(REL.getKey())) {
             return Attribute.newBuilder().setId("REL").setNamespace(
                     Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
                     .setName("Releasable To").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF).setFqn(fqn.toString()).build();
-            default:
-                return null;
+        }
+        else {
+            return null;
         }
     }
 
     private Value mockValueFor(Autoconfigure.AttributeValueFQN fqn) throws AutoConfigureException {
         Autoconfigure.AttributeNameFQN an = fqn.prefix();
+        System.out.println(an);
         Attribute a = mockAttributeFor(an);
+        System.out.println(a);
         String v = fqn.value();
         Value p = Value.newBuilder()
         .setId(a.getId() + ":" + v)
@@ -155,25 +174,23 @@ private Value mockValueFor(Autoconfigure.AttributeValueFQN fqn) throws AutoConfi
         .setFqn(fqn.toString())
         .build();
 
-        switch (an.getKey()) {
-            case "N2K":
-                switch (v.toUpperCase()) {
-                    case "INT":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_UK).build()).build();
-                        break;
-                    case "HCS":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_US_HCS).build()).build();
-                        break;
-                    case "SI":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_US_SA).build()).build();
-                        break;
-                }
-                break;
-
-            case "REL":
+        if (an.getKey().equals(N2K.getKey())){
+            switch (v.toUpperCase()) {
+                case "INT":
+                    p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(KAS_UK).build()).build();
+                    break;
+                case "HCS":
+                    p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(KAS_US_HCS).build()).build();
+                    break;
+                case "SI":
+                    p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(KAS_US_SA).build()).build();
+                    break;
+            }
+        }
+        else if (an.getKey().equals(REL.getKey())) {
                 switch (v.toUpperCase()) {
                     case "FVEY":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_AU).build())
+                        p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(KAS_AU).build())
                         .addGrants(1, KeyAccessServer.newBuilder().setUri(KAS_CA).build())
                         .addGrants(1, KeyAccessServer.newBuilder().setUri(KAS_UK).build())
                         .addGrants(1, KeyAccessServer.newBuilder().setUri(KAS_NZ).build())
@@ -181,31 +198,29 @@ private Value mockValueFor(Autoconfigure.AttributeValueFQN fqn) throws AutoConfi
                         .build();
                         break;
                     case "AUS":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_AU).build())
+                        p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(KAS_AU).build())
                         .build();
                         break;
                     case "CAN":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_CA).build())
+                        p = p.toBuilder().addGrants(0, KeyAccessServer.newBuilder().setUri(KAS_CA).build())
                         .build();
                         break;
                     case "GBR":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_UK).build())
+                        p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(KAS_UK).build())
                         .build();
                         break;
                     case "NZL":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_NZ).build())
+                        p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(KAS_NZ).build())
                         .build();
                         break;
                     case "USA":
-                        p = p.toBuilder().setGrants(0, KeyAccessServer.newBuilder().setUri(KAS_US).build())
+                        p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(KAS_US).build())
                         .build();
                         break;
                 }
-                break;
-
-            case "CLS":
-                // defaults only
-                break;
+        }
+        else if (an.getKey().equals(CLS.getKey())){
+            // defaults only
         }
         return p;
     }
@@ -236,7 +251,7 @@ public void testAttributeFromMalformedURL() {
             new TestCase("with value", "http://e/attr/a/value/b")
         )) {
             assertThatThrownBy(() -> new Autoconfigure.AttributeNameFQN(tc.getU()))
-                .isInstanceOf(IllegalArgumentException.class);
+                .isInstanceOf(AutoConfigureException.class);
         }
     }
 
@@ -273,8 +288,8 @@ public void testAttributeValueFromMalformedURL() {
 
         for (TestCase tc : testCases) {
             assertThatThrownBy(() -> new AttributeValueFQN(tc.getU()))
-                .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining("Invalid URL format");
+                .isInstanceOf(AutoConfigureException.class)
+                .hasMessageContaining("invalid type");
         }
     }
 
@@ -289,13 +304,14 @@ public void testConfigurationServicePutGet() {
         );
 
         for (ConfigurationTestCase tc : testCases) {
+            System.out.println(tc.name);
             assertDoesNotThrow(() -> {
                 List<Value> v = valuesToPolicy(tc.getPolicy().toArray(new AttributeValueFQN[0]));
                 Granter grants = Autoconfigure.newGranterFromAttributes(v.toArray(new Value[0]));
                 assertThat(grants).isNotNull();
                 assertThat(grants.getGrants()).hasSize(tc.getSize());
                 assertThat(policyToStringKeys(tc.getPolicy())).containsAll(grants.getGrants().keySet());
-                Set<String> actualKases = Collections.emptySet();  ;
+                Set<String> actualKases = new HashSet<>();
                 for (Autoconfigure.KeyAccessGrant g : grants.getGrants().values()) {
                     assertThat(g).isNotNull();
                     for (String k : g.kases){
@@ -389,7 +405,7 @@ public void testReasonerConstructAttributeBoolean() {
                 assertThat(actualAB.toString().toLowerCase()).isEqualTo(tc.getAts().toLowerCase());
 
                 BooleanKeyExpression actualKeyed = reasoner.insertKeysForAttribute(actualAB);
-                assertThat(actualKeyed).isEqualTo(tc.getKeyed());
+                assertThat(actualKeyed.toString()).isEqualTo(tc.getKeyed());
 
                 String reduced = actualKeyed.reduce().toString();
                 assertThat(reduced).isEqualTo(tc.getReduced());
@@ -402,29 +418,6 @@ public void testReasonerConstructAttributeBoolean() {
         }
     }
 
-    // @Test
-    // public void testSpongeCase() {
-    //     for (TestCase tc : List.of(
-    //         new TestCase("attr", "http://a/attr/b", "http://a/attr/b"),
-    //         new TestCase("value", "http://a/attr/b/value/c", "http://a/attr/b/Value/C"),
-    //         new TestCase("with value", "http://a/attr/b/value/c/d", "http://a/attr/b/Value/C/D")
-    //     )) {
-    //         String s = spongeCase(tc.getU());
-    //         assertThat(s).isEqualTo(tc.getExpected());
-    //     }
-    // }
-
-    // @Test
-    // public void testMessUp() throws AutoConfigureException {
-    //     for (TestCase tc : List.of(
-    //         new TestCase("n2k", n2kInt, "https://virtru.com/attr/need%20to%20know/value/iNt"),
-    //         new TestCase("cls", clsS, "https://virtru.com/attr/Classification/value/sEcReT"),
-    //         new TestCase("rel", rel2usa, "https://virtru.com/attr/Releasable%20To/value/uSa")
-    //     )) {
-    //         Autoconfigure.AttributeValueFQN v = messUpV(tc.getA());
-    //         assertThat(v.toString()).isEqualTo(tc.getExpected());
-    //     }
-    // }
 
     private static class TestCase {
         private final String n;

From 8d2ea13d65bee34da749cd0949659da75f88cefc Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Mon, 19 Aug 2024 21:02:18 -0400
Subject: [PATCH 13/23] tests passing

---
 .../opentdf/platform/sdk/Autoconfigure.java   | 76 +++++++++++--------
 .../platform/sdk/AutoconfigureTest.java       | 16 ++--
 2 files changed, 50 insertions(+), 42 deletions(-)

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index ebe7b4aa..429d36a4 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -59,6 +59,20 @@ public SplitStep(String kas, String splitId) {
             this.kas = kas;
             this.splitID = splitId;
         }
+
+        public boolean equals(Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (obj == null || !(obj instanceof SplitStep)) {
+                return false;
+            }
+            SplitStep ss = (SplitStep) obj;
+            if ((this.kas.equals(ss.kas)) && (this.splitID.equals(ss.splitID))){
+                return true;
+            }
+            return false;
+        }
     }
 
     // Utility class for an attribute name FQN.
@@ -327,23 +341,19 @@ public BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e)
             return new BooleanKeyExpression(kcs);
         }
 
-        AttributeBooleanExpression constructAttributeBoolean() {
+        AttributeBooleanExpression constructAttributeBoolean() throws AutoConfigureException {
             Map<String, SingleAttributeClause> prefixes = new HashMap<>();
             List<String> sortedPrefixes = new ArrayList<>();
             for (AttributeValueFQN aP : policy) {
-                try {
-                    AttributeNameFQN a = aP.prefix();
-                    String p = a.toString().toLowerCase();
-                    SingleAttributeClause clause = prefixes.get(p);
-                    if (clause != null) {
-                        clause.values.add(aP);
-                    } else if (byAttribute(aP) != null) {
-                        var x = new SingleAttributeClause(byAttribute(aP).attr, Collections.singletonList(aP));
-                        prefixes.put(p, x);
-                        sortedPrefixes.add(p);
-                    }
-                } catch (AutoConfigureException e) {
-                    // Handle exception
+                AttributeNameFQN a = aP.prefix();
+                String p = a.toString().toLowerCase();
+                SingleAttributeClause clause = prefixes.get(p);
+                if (clause != null) {
+                    clause.values.add(aP);
+                } else if (byAttribute(aP) != null) {
+                    var x = new SingleAttributeClause(byAttribute(aP).attr, new ArrayList<AttributeValueFQN>(Arrays.asList(aP)));
+                    prefixes.put(p, x);
+                    sortedPrefixes.add(p);
                 }
             }
 
@@ -535,14 +545,14 @@ public int len() {
             public BooleanKeyExpression reduce() {
                 List<Disjunction> conjunction = new ArrayList<>();
                 for (KeyClause v : values) {
-                    if (v.operator.equals("anyOf")) {  // Assuming "anyOf" is a constant in Java
+                    if (v.operator.equals(RuleTypes.ANY_OF)) {
                         Disjunction terms = sortedNoDupes(v.values);
                         if (!terms.isEmpty() && !within(conjunction, terms)) {
                             conjunction.add(terms);
                         }
                     } else {
                         for (PublicKeyInfo k : v.values) {
-                            if (k.getKas().equals("emptyTerm")) {  // Assuming "emptyTerm" is a constant in Java
+                            if (k.getKas().equals(RuleTypes.EMPTY_TERM)) {
                                 continue;
                             }
                             Disjunction terms = new Disjunction();
@@ -563,11 +573,26 @@ public BooleanKeyExpression reduce() {
                     for (String k : d) {
                         pki.add(new PublicKeyInfo(k));
                     }
-                    newValues.add(new KeyClause("anyOf", pki));  // Assuming "anyOf" is a constant in Java
+                    newValues.add(new KeyClause(RuleTypes.ANY_OF, pki));
                 }
                 return new BooleanKeyExpression(newValues);
             }
 
+            public Disjunction sortedNoDupes(List<PublicKeyInfo> l) {
+                Set<String> set = new HashSet<>();
+                Disjunction list = new Disjunction();
+    
+                for (PublicKeyInfo e : l) {
+                    String kas = e.getKas();
+                    if (!kas.equals(RuleTypes.EMPTY_TERM) && !set.contains(kas)) {
+                        set.add(kas);
+                        list.add(kas);
+                    }
+                }
+    
+                Collections.sort(list);
+                return list;
+            }
 
         }
 
@@ -605,6 +630,7 @@ public boolean equals(Object obj) {
                 }
                 return true;
             }
+
         }
 
         public static boolean within(List<Disjunction> list, Disjunction e) {
@@ -616,22 +642,6 @@ public static boolean within(List<Disjunction> list, Disjunction e) {
             return false;
         }
 
-        public static Disjunction sortedNoDupes(List<PublicKeyInfo> l) {
-            Set<String> set = new HashSet<>();
-            Disjunction list = new Disjunction();
-
-            for (PublicKeyInfo e : l) {
-                String kas = e.getKas();
-                if (!kas.equals(RuleTypes.EMPTY_TERM) && !set.contains(kas)) {
-                    set.add(kas);
-                    list.add(kas);
-                }
-            }
-
-            Collections.sort(list);
-            return list;
-        }
-
         public static String ruleToOperator(AttributeRuleTypeEnum e) {
             switch (e) {
                 case ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF:
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
index 8a814700..f148e148 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
@@ -2,7 +2,6 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.Assert.fail;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 
 import io.opentdf.platform.policy.Attribute;
@@ -19,10 +18,8 @@
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.regex.Matcher;
@@ -410,8 +407,13 @@ public void testReasonerConstructAttributeBoolean() {
                 String reduced = actualKeyed.reduce().toString();
                 assertThat(reduced).isEqualTo(tc.getReduced());
 
-                int i = 0;
-                List<SplitStep> plan = reasoner.plan(tc.getDefaults(), () -> String.valueOf(i + 1));
+                var wrapper = new Object(){ int i = 0; };
+                List<SplitStep> plan = reasoner.plan(tc.getDefaults(), () -> {
+                    return String.valueOf(wrapper.i++ + 1);
+                }
+                
+                );
+                assertThat(plan.size()).isEqualTo(tc.getPlan().size());
                 assertThat(plan).isEqualTo(tc.getPlan());
             }
             );
@@ -443,10 +445,6 @@ private static class TestCase {
             this(n, u, null, null, null);
         }
 
-        TestCase(String n, Autoconfigure.AttributeValueFQN a) {
-            this(n, a.toString(), null, null, null);
-        }
-
         public String getU() {
             return u;
         }

From dc8a1ba194d1499902386cfac20840e10bb53981 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <35498075+elizabethhealy@users.noreply.github.com>
Date: Tue, 20 Aug 2024 09:59:50 -0400
Subject: [PATCH 14/23] multikas e2e (#122)

---
 .github/workflows/checks.yaml                 |  4 +-
 .../java/io/opentdf/platform/Command.java     | 16 ++---
 .../opentdf/platform/sdk/Autoconfigure.java   | 29 +++++++--
 .../java/io/opentdf/platform/sdk/Config.java  | 64 ++++++++++++++++++-
 .../java/io/opentdf/platform/sdk/TDF.java     | 59 +++++++++++++++--
 .../io/opentdf/platform/sdk/ConfigTest.java   |  8 +--
 .../io/opentdf/platform/sdk/TDFE2ETest.java   |  2 +-
 .../java/io/opentdf/platform/sdk/TDFTest.java | 29 +++++++--
 8 files changed, 172 insertions(+), 39 deletions(-)

diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index d985651e..947833fe 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -2,8 +2,6 @@ name: "Checks"
 
 on:
   pull_request:
-    branches:
-      - main
   push:
     branches:
       - main
@@ -139,7 +137,7 @@ jobs:
             --client-secret=secret \
             --platform-endpoint=localhost:8080 \
             -i \
-            encrypt --kas-url=localhost:8080 --mime-type=text/plain --attr https://example.com/attr/attr1/value/value1 -f data -m 'here is some metadata' > test.tdf
+            encrypt --kas-url=localhost:8080 --mime-type=text/plain --attr https://example.com/attr/attr1/value/value1 --autoconfigure=false -f data -m 'here is some metadata' > test.tdf
 
           java -jar target/cmdline.jar \
             --client-id=opentdf-sdk \
diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java
index 64886f65..ee723844 100644
--- a/cmdline/src/main/java/io/opentdf/platform/Command.java
+++ b/cmdline/src/main/java/io/opentdf/platform/Command.java
@@ -13,12 +13,10 @@
 import javax.crypto.NoSuchPaddingException;
 import java.io.BufferedInputStream;
 import java.io.BufferedOutputStream;
-import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.PrintWriter;
-import java.io.StringWriter;
 import java.nio.ByteBuffer;
 import java.nio.channels.FileChannel;
 import java.nio.file.Path;
@@ -31,7 +29,6 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Consumer;
-import java.util.stream.Stream;
 
 @CommandLine.Command(name = "tdf")
 class Command {
@@ -55,6 +52,7 @@ void encrypt(
             @Option(names = {"-m", "--metadata"}, defaultValue = Option.NULL_VALUE) Optional<String> metadata,
             // cant split on optional parameters
             @Option(names = {"-a", "--attr"}, defaultValue = Option.NULL_VALUE) Optional<String> attributes,
+            @Option(names = {"-c", "--autoconfigure"}, defaultValue = Option.NULL_VALUE) Optional<Boolean> autoconfigure,
             @Option(names = {"--mime-type"}, defaultValue = Option.NULL_VALUE) Optional<String> mimeType) throws
             IOException, JOSEException {
 
@@ -68,15 +66,17 @@ void encrypt(
         List<Consumer<Config.TDFConfig>> configs = new ArrayList<>();
         configs.add(Config.withKasInformation(kasInfos));
         metadata.map(Config::withMetaData).ifPresent(configs::add);
+        autoconfigure.map(Config::withAutoconfigure).ifPresent(configs::add);
         mimeType.map(Config::withMimeType).ifPresent(configs::add);
-        attributes.ifPresent(attr -> {
-            configs.add(Config.withDataAttributes(attr.split(",")));
-        });
-
+        if (attributes.isPresent()){
+            configs.add(Config.withDataAttributes(attributes.get().split(",")));
+        }
         var tdfConfig = Config.newTDFConfig(configs.toArray(Consumer[]::new));
         try (var in = file.isEmpty() ? new BufferedInputStream(System.in) : new FileInputStream(file.get())) {
             try (var out = new BufferedOutputStream(System.out)) {
-                new TDF().createTDF(in, out, tdfConfig, sdk.getServices().kas());
+                new TDF().createTDF(in, out, tdfConfig, 
+                sdk.getServices().kas(), 
+                sdk.getServices().attributes());
             }
         }
     }
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index 429d36a4..9cf20edb 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -1,5 +1,6 @@
 package io.opentdf.platform.sdk;
 
+import java.io.IOException;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
@@ -31,7 +32,7 @@
 import io.opentdf.platform.policy.AttributeRuleTypeEnum;
 
 // Error handling class
-class AutoConfigureException extends Exception {
+class AutoConfigureException extends IOException {
     public AutoConfigureException(String message) {
         super(message);
     }
@@ -165,6 +166,20 @@ public AttributeValueFQN(String url) throws AutoConfigureException {
         public String toString() {
             return url;
         }
+        
+        public boolean equals(Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (obj == null || !(obj instanceof AttributeValueFQN)) {
+                return false;
+            }
+            AttributeValueFQN afqn = (AttributeValueFQN) obj;
+            if ((this.url.equals(afqn.url)) && (this.key.equals(afqn.key))){
+                return true;
+            }
+            return false;
+        }
 
         public String getKey() {
             return key;
@@ -273,7 +288,7 @@ public interface StringOperator {
         }
 
 
-        public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID) throws Exception {
+        public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID) throws AutoConfigureException {
             AttributeBooleanExpression b = constructAttributeBoolean();
             BooleanKeyExpression k = insertKeysForAttribute(b);
             if (k == null) {
@@ -307,7 +322,7 @@ public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID
             return steps;
         }
 
-        public BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e) throws Exception {
+        public BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e) throws AutoConfigureException {
             List<KeyClause> kcs = new ArrayList<>(e.must.size());
     
             for (SingleAttributeClause clause : e.must) {
@@ -316,7 +331,7 @@ public BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e)
                 for (AttributeValueFQN term : clause.values) {
                     KeyAccessGrant grant = byAttribute(term);
                     if (grant == null) {
-                        throw new Exception(String.format("no definition or grant found for [%s]", term));
+                        throw new AutoConfigureException(String.format("no definition or grant found for [%s]", term));
                     }
     
                     List<String> kases = grant.kases;
@@ -660,7 +675,7 @@ public static String ruleToOperator(AttributeRuleTypeEnum e) {
     }
 
     // Gets a list of directory of KAS grants for a list of attribute FQNs
-    public static Granter newGranterFromService(SDK.AttributesService as, AttributeValueFQN... fqns) throws Exception {
+    public static Granter newGranterFromService(SDK.AttributesService as, AttributeValueFQN... fqns) throws AutoConfigureException {
         String[] fqnsStr = new String[fqns.length];
         for (int i = 0; i < fqns.length; i++) {
             fqnsStr[i] = fqns[i].toString();
@@ -704,7 +719,7 @@ public static Granter newGranterFromService(SDK.AttributesService as, AttributeV
     // Given a policy (list of data attributes or tags),
     // get a set of grants from attribute values to KASes.
     // Unlike `NewGranterFromService`, this works offline.
-    public static Granter newGranterFromAttributes(Value... attrs) throws Exception {
+    public static Granter newGranterFromAttributes(Value... attrs) throws AutoConfigureException {
         List<AttributeValueFQN> policyList = new ArrayList<>(attrs.length);
         Map<String, KeyAccessGrant> grantsMap = new HashMap<>();
 
@@ -721,7 +736,7 @@ public static Granter newGranterFromAttributes(Value... attrs) throws Exception
             grants.policy.add(fqn);
             Attribute def = v.getAttribute();
             if (def == null) {
-                throw new Exception("No associated definition with value [" + fqn.toString() + "]");
+                throw new AutoConfigureException("No associated definition with value [" + fqn.toString() + "]");
             }
 
             grants.addAllGrants(fqn, def.getGrantsList(), def);
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
index 108bfffe..a7a081c9 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
@@ -1,9 +1,12 @@
 package io.opentdf.platform.sdk;
 
+import io.opentdf.platform.sdk.Autoconfigure.AttributeValueFQN;
 import io.opentdf.platform.sdk.nanotdf.ECCMode;
 import io.opentdf.platform.sdk.nanotdf.NanoTDFType;
 import io.opentdf.platform.sdk.nanotdf.SymmetricAndPayloadConfig;
 
+import io.opentdf.platform.policy.Value;
+
 import java.util.*;
 import java.util.function.Consumer;
 
@@ -30,6 +33,7 @@ public static class KASInfo {
         public String URL;
         public String PublicKey;
         public String KID;
+        public Boolean Default;
     }
 
 
@@ -70,6 +74,7 @@ public static Consumer<TDFReaderConfig> withAssertionVerificationKeys(AssertionV
     }
 
     public static class TDFConfig {
+        public Boolean autoconfigure;
         public int defaultSegmentSize;
         public boolean enableEncryption;
         public TDFFormat tdfFormat;
@@ -78,13 +83,15 @@ public static class TDFConfig {
         public String metaData;
         public IntegrityAlgorithm integrityAlgorithm;
         public IntegrityAlgorithm segmentIntegrityAlgorithm;
-        public List<String> attributes;
+        public List<Autoconfigure.AttributeValueFQN> attributes;
+        public List<Value> attributeValues;
         public List<KASInfo> kasInfoList;
         public List<io.opentdf.platform.sdk.AssertionConfig> assertionConfigList;
         public String mimeType;
         public List<Autoconfigure.SplitStep> splitPlan;
 
         public TDFConfig() {
+            this.autoconfigure = true;
             this.defaultSegmentSize = DEFAULT_SEGMENT_SIZE;
             this.enableEncryption = true;
             this.tdfFormat = TDFFormat.JSONFormat;
@@ -107,9 +114,46 @@ public static TDFConfig newTDFConfig(Consumer<TDFConfig>... options) {
         return config;
     }
 
-    public static Consumer<TDFConfig> withDataAttributes(String... attributes) {
+    public static Consumer<TDFConfig> withDataAttributes(String... attributes) throws AutoConfigureException {
+        List<Autoconfigure.AttributeValueFQN> attrValFqns = new ArrayList<Autoconfigure.AttributeValueFQN>();
+        for (String a : attributes){
+            Autoconfigure.AttributeValueFQN attrValFqn = new Autoconfigure.AttributeValueFQN(a);
+            attrValFqns.add(attrValFqn);
+        }
         return (TDFConfig config) -> {
-            Collections.addAll(config.attributes, attributes);
+            config.attributeValues = null;
+            config.attributes.addAll(attrValFqns);
+        };
+    }
+
+    public static Consumer<TDFConfig> withDataAttributeValues(String... attributes) throws AutoConfigureException {
+        List<Autoconfigure.AttributeValueFQN> attrValFqns = new ArrayList<Autoconfigure.AttributeValueFQN>();
+        for (String a : attributes){
+            Autoconfigure.AttributeValueFQN attrValFqn = new Autoconfigure.AttributeValueFQN(a);
+            attrValFqns.add(attrValFqn);
+        }
+        return (TDFConfig config) -> {
+            config.attributeValues = null;
+            config.attributes.addAll(attrValFqns);
+        };
+    }
+
+    // WithDataAttributeValues appends the given data attributes to the bound policy.
+    // Unlike `WithDataAttributes`, this will not trigger an attribute definition lookup
+    // during autoconfigure. That is, to use autoconfigure in an 'offline' context,
+    // you must first store the relevant attribute information locally and load
+    // it to the `CreateTDF` method with this option.
+    public static Consumer<TDFConfig> withDataAttributeValues(Value... attributes) throws AutoConfigureException {
+        List<Autoconfigure.AttributeValueFQN> attrValFqns = new ArrayList<Autoconfigure.AttributeValueFQN>();
+        List<Value> attrVals = new ArrayList<Value>();
+        for (Value a : attributes) {
+            attrVals.add(a);
+            AttributeValueFQN afqn = new Autoconfigure.AttributeValueFQN(a.getFqn());
+            attrValFqns.add(afqn);
+        }
+        return (TDFConfig config) -> {
+            config.attributes.addAll(attrValFqns);
+            config.attributeValues.addAll(attrVals);
         };
     }
 
@@ -119,6 +163,13 @@ public static Consumer<TDFConfig> withKasInformation(KASInfo... kasInfoList) {
         };
     }
 
+    public static Consumer<TDFConfig> withSplitPlan(Autoconfigure.SplitStep... p) {
+        return (TDFConfig config) -> {
+            config.splitPlan = new ArrayList<>(Arrays.asList(p));
+            config.autoconfigure = false;
+        };
+    }
+
     public static Consumer<TDFConfig> withAssertionConfig(io.opentdf.platform.sdk.AssertionConfig... assertionList) {
         return (TDFConfig config) -> {
             Collections.addAll(config.assertionConfigList, assertionList);
@@ -133,6 +184,13 @@ public static Consumer<TDFConfig> withSegmentSize(int size) {
         return (TDFConfig config) -> config.defaultSegmentSize = size;
     }
 
+    public static Consumer<TDFConfig> withAutoconfigure(boolean enable) {
+        return (TDFConfig config) -> {
+            config.autoconfigure = enable;
+            config.splitPlan = null;
+        };
+    }
+
 //    public static Consumer<TDFConfig> withDisableEncryption() {
 //        return (TDFConfig config) -> config.enableEncryption = false;
 //    }
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index c8638cb4..8b8b98fc 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -8,6 +8,12 @@
 import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.SignedJWT;
 
+import io.opentdf.platform.policy.Value;
+
+import io.opentdf.platform.sdk.Config.TDFConfig;
+import io.opentdf.platform.sdk.Autoconfigure.AttributeValueFQN;
+import io.opentdf.platform.sdk.Config.KASInfo;
+
 import com.nimbusds.jose.crypto.RSASSASigner;
 import com.nimbusds.jose.crypto.MACSigner;
 import org.apache.commons.codec.DecoderException;
@@ -148,16 +154,16 @@ public TDFObject() {
             this.size = 0;
         }
 
-        PolicyObject createPolicyObject(List<String> attributes) {
+        PolicyObject createPolicyObject(List<Autoconfigure.AttributeValueFQN> attributes) {
             PolicyObject policyObject = new PolicyObject();
             policyObject.body = new PolicyObject.Body();
             policyObject.uuid = UUID.randomUUID().toString();
             policyObject.body.dataAttributes = new ArrayList<>();
             policyObject.body.dissem = new ArrayList<>();
 
-            for (String attribute: attributes) {
+            for (Autoconfigure.AttributeValueFQN attribute: attributes) {
                 PolicyObject.AttributeObject attributeObject = new PolicyObject.AttributeObject();
-                attributeObject.attribute = attribute;
+                attributeObject.attribute = attribute.toString();
                 policyObject.body.dataAttributes.add(attributeObject);
             }
             return policyObject;
@@ -172,7 +178,7 @@ private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
             String base64PolicyObject  = encoder.encodeToString(gson.toJson(policyObject).getBytes(StandardCharsets.UTF_8));
             List<byte[]> symKeys = new ArrayList<>();
             Map<String, Config.KASInfo> latestKASInfo = new HashMap<>();
-            if (tdfConfig.splitPlan.isEmpty()) {
+            if (tdfConfig.splitPlan == null || tdfConfig.splitPlan.isEmpty()) {
                 // Default split plan: Split keys across all KASes
                 List<Autoconfigure.SplitStep> splitPlan = new ArrayList<>(tdfConfig.kasInfoList.size());
                 int i = 0;
@@ -267,6 +273,7 @@ private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
                     keyAccess.policyBinding = policyBinding;
                     keyAccess.wrappedKey = encoder.encodeToString(wrappedKey);
                     keyAccess.encryptedMetadata = encryptedMetadata;
+                    keyAccess.sid = splitID;
 
                     manifest.encryptionInformation.keyAccessObj.add(keyAccess);
                 }
@@ -369,9 +376,30 @@ private static String calculateSignature(byte[] data, byte[] secret, Config.Inte
 
     public TDFObject createTDF(InputStream payload,
                                OutputStream outputStream,
-                               Config.TDFConfig tdfConfig, SDK.KAS kas) throws IOException, JOSEException {
-        if (tdfConfig.kasInfoList.isEmpty()) {
-            throw new KasInfoMissing("kas information is missing");
+                               Config.TDFConfig tdfConfig, SDK.KAS kas, SDK.AttributesService attrService) throws IOException, JOSEException {
+
+        if (tdfConfig.autoconfigure) {
+            Autoconfigure.Granter granter = new Autoconfigure.Granter(new ArrayList<>());
+            if (tdfConfig.attributeValues != null && !tdfConfig.attributeValues.isEmpty()) {
+                granter = Autoconfigure.newGranterFromAttributes(tdfConfig.attributeValues.toArray(new Value[0]));
+            } else if (tdfConfig.attributes != null && !tdfConfig.attributes.isEmpty()) {
+                granter = Autoconfigure.newGranterFromService(attrService, tdfConfig.attributes.toArray(new AttributeValueFQN[0]));
+            }
+        
+            if (granter == null) {
+                throw new AutoConfigureException("Failed to create Granter"); // Replace with appropriate error handling
+            }
+        
+            List<String> dk = defaultKases(tdfConfig);
+            tdfConfig.splitPlan = granter.plan(dk, () -> UUID.randomUUID().toString());
+        
+            if (tdfConfig.splitPlan == null) {
+                throw new AutoConfigureException("Failed to generate Split Plan"); // Replace with appropriate error handling
+            }
+        }
+
+        if (tdfConfig.kasInfoList.isEmpty() && (tdfConfig.splitPlan==null || tdfConfig.splitPlan.isEmpty())) {
+            throw new KasInfoMissing("kas information is missing, no key access template specified or inferred");
         }
 
         TDFObject tdfObject = new TDFObject();
@@ -495,6 +523,23 @@ public TDFObject createTDF(InputStream payload,
         return tdfObject;
     }
 
+    public List<String> defaultKases(TDFConfig config) {
+        List<String> allk = new ArrayList<>();
+        List<String> defk = new ArrayList<>();
+
+        for (KASInfo kasInfo : config.kasInfoList) {
+            if (kasInfo.Default != null && kasInfo.Default) {
+                defk.add(kasInfo.URL);
+            } else if (defk.isEmpty()) {
+                allk.add(kasInfo.URL);
+            }
+        }
+        if (defk.isEmpty()) {
+            return allk;
+        }
+        return defk;
+    }
+
     private void fillInPublicKeyInfo(List<Config.KASInfo> kasInfoList, SDK.KAS kas) {
         for (var kasInfo: kasInfoList) {
             if (kasInfo.PublicKey != null && !kasInfo.PublicKey.isBlank()) {
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java
index 0f079f81..cae23434 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java
@@ -20,11 +20,11 @@ void newTDFConfig_shouldCreateDefaultConfig() {
     }
 
     @Test
-    void withDataAttributes_shouldAddAttributes() {
-        Config.TDFConfig config = Config.newTDFConfig(Config.withDataAttributes("attr1", "attr2"));
+    void withDataAttributes_shouldAddAttributes() throws AutoConfigureException {
+        Config.TDFConfig config = Config.newTDFConfig(Config.withDataAttributes("https://example.com/attr/attr1/value/value1", "https://example.com/attr/attr2/value/value2"));
         assertEquals(2, config.attributes.size());
-        assertTrue(config.attributes.contains("attr1"));
-        assertTrue(config.attributes.contains("attr2"));
+        assertTrue(config.attributes.contains(new Autoconfigure.AttributeValueFQN("https://example.com/attr/attr1/value/value1")));
+        assertTrue(config.attributes.contains(new Autoconfigure.AttributeValueFQN("https://example.com/attr/attr2/value/value2")));
     }
 
     @Test
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFE2ETest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFE2ETest.java
index eea443c7..fb44663c 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFE2ETest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFE2ETest.java
@@ -35,7 +35,7 @@ public void createAndDecryptTdfIT() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, sdk.kas());
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, sdk.kas(), sdk.attributes());
 
         var unwrappedData = new java.io.ByteArrayOutputStream();
         var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), sdk.kas());
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
index 8fad4ca1..ab773673 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
@@ -7,6 +7,8 @@
 import com.nimbusds.jose.crypto.RSASSAVerifier;
 import com.nimbusds.jose.jwk.RSAKey;
 
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
 import io.opentdf.platform.sdk.nanotdf.NanoTDFType;
 import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
 import org.junit.jupiter.api.BeforeAll;
@@ -69,6 +71,15 @@ public byte[] unwrapNanoTDF(NanoTDFType.ECCurve curve, String header, String kas
             return null;
         }
     };
+    private static SDK.AttributesService attrServ = new SDK.AttributesService() {
+        @Override
+        public void close() {}
+
+        @Override
+        public GetAttributeValuesByFqnsResponse getAttributeValuesByFqn(GetAttributeValuesByFqnsRequest request) {
+            return GetAttributeValuesByFqnsResponse.newBuilder().build();
+        }
+    };
 
     private static ArrayList<KeyPair> keypairs = new ArrayList<>();
 
@@ -97,6 +108,7 @@ void testSimpleTDFEncryptAndDecrypt() throws Exception {
         assertion1.assertionKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, key);
 
         Config.TDFConfig config = Config.newTDFConfig(
+                Config.withAutoconfigure(false),
                 Config.withKasInformation(getKASInfos()),
                 Config.withMetaData("here is some metadata"),
                 Config.withAssertionConfig(assertion1)
@@ -107,7 +119,7 @@ void testSimpleTDFEncryptAndDecrypt() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
 
         var assertionVerificationKeys = new Config.AssertionVerificationKeys();
         assertionVerificationKeys.defaultKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, key);
@@ -142,6 +154,7 @@ void testSimpleTDFWithAssertionWithRS256() throws Exception {
                 keypair.getPrivate());
 
         Config.TDFConfig config = Config.newTDFConfig(
+                Config.withAutoconfigure(false),
                 Config.withKasInformation(getKASInfos()),
                 Config.withAssertionConfig(assertionConfig)
         );
@@ -151,7 +164,7 @@ void testSimpleTDFWithAssertionWithRS256() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
 
         var assertionVerificationKeys = new Config.AssertionVerificationKeys();
         assertionVerificationKeys.keys.put(assertion1Id,
@@ -181,6 +194,7 @@ void testSimpleTDFWithAssertionWithHS256() throws Exception {
         assertionConfig1.statement.value = "ICAgIDxlZGoOkVkaD4=";
 
         Config.TDFConfig config = Config.newTDFConfig(
+                Config.withAutoconfigure(false),
                 Config.withKasInformation(getKASInfos()),
                 Config.withAssertionConfig(assertionConfig1)
         );
@@ -190,7 +204,7 @@ void testSimpleTDFWithAssertionWithHS256() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
 
         var unwrappedData = new ByteArrayOutputStream();
         var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), kas);
@@ -206,6 +220,7 @@ public void testCreatingTDFWithMultipleSegments() throws Exception {
         var random = new Random();
 
         Config.TDFConfig config = Config.newTDFConfig(
+                Config.withAutoconfigure(false),
                 Config.withKasInformation(getKASInfos()),
                 // use a random segment size that makes sure that we will use multiple segments
                 Config.withSegmentSize(1 + random.nextInt(20))
@@ -217,7 +232,7 @@ public void testCreatingTDFWithMultipleSegments() throws Exception {
         var plainTextInputStream = new ByteArrayInputStream(data);
         var tdfOutputStream = new ByteArrayOutputStream();
         var tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
         var unwrappedData = new ByteArrayOutputStream();
         var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), kas);
         reader.readPayload(unwrappedData);
@@ -262,10 +277,11 @@ public void write(byte[] b, int off, int len) {}
 
         var tdf = new TDF(maxSize);
         var tdfConfig = Config.newTDFConfig(
+                Config.withAutoconfigure(false),
                 Config.withKasInformation(getKASInfos()),
                 Config.withSegmentSize(1 + random.nextInt(128)));
         assertThrows(TDF.DataSizeNotSupported.class,
-                () -> tdf.createTDF(is, os, tdfConfig, kas),
+                () -> tdf.createTDF(is, os, tdfConfig, kas, attrServ),
                 "didn't throw an exception when we created TDF that was too large");
         assertThat(numReturned.get())
                 .withFailMessage("test returned the wrong number of bytes")
@@ -278,6 +294,7 @@ public void testCreateTDFWithMimeType() throws Exception {
         final String mimeType = "application/pdf";
 
         Config.TDFConfig config = Config.newTDFConfig(
+                Config.withAutoconfigure(false),
                 Config.withKasInformation(getKASInfos()),
                 Config.withMimeType(mimeType)
         );
@@ -287,7 +304,7 @@ public void testCreateTDFWithMimeType() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
 
         var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), kas);
         assertThat(reader.getManifest().payload.mimeType).isEqualTo(mimeType);

From b17950ceb479e22dfe7388c73c87aa72761d8235 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 20 Aug 2024 10:00:38 -0400
Subject: [PATCH 15/23] fix ci trigger

---
 .github/workflows/checks.yaml | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index 947833fe..59069682 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -2,6 +2,8 @@ name: "Checks"
 
 on:
   pull_request:
+    branches:
+      - main
   push:
     branches:
       - main

From d879fffeefbb8cc9e248d7cadcb6a3a57a445f83 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 20 Aug 2024 13:13:22 -0400
Subject: [PATCH 16/23] addressing some comments

---
 .../java/io/opentdf/platform/Command.java     |   5 +-
 .../opentdf/platform/sdk/Autoconfigure.java   | 111 ++++++++----------
 2 files changed, 55 insertions(+), 61 deletions(-)

diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java
index ee723844..2e358f02 100644
--- a/cmdline/src/main/java/io/opentdf/platform/Command.java
+++ b/cmdline/src/main/java/io/opentdf/platform/Command.java
@@ -75,8 +75,9 @@ void encrypt(
         try (var in = file.isEmpty() ? new BufferedInputStream(System.in) : new FileInputStream(file.get())) {
             try (var out = new BufferedOutputStream(System.out)) {
                 new TDF().createTDF(in, out, tdfConfig, 
-                sdk.getServices().kas(), 
-                sdk.getServices().attributes());
+                    sdk.getServices().kas(), 
+                    sdk.getServices().attributes()
+                );
             }
         }
     }
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index 9cf20edb..11f5e5cf 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -1,6 +1,7 @@
 package io.opentdf.platform.sdk;
 
 import java.io.IOException;
+import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
@@ -36,10 +37,18 @@ class AutoConfigureException extends IOException {
     public AutoConfigureException(String message) {
         super(message);
     }
+
+    public AutoConfigureException(Exception e) {
+        super(e);
+    }
+
+    public AutoConfigureException(String message, Exception e) {
+        super(message, e);
+    }
 }
 
 // Attribute rule types: operators!
-class RuleTypes {
+class RuleType {
     public static final String HIERARCHY = "hierarchy";
     public static final String ALL_OF = "allOf";
     public static final String ANY_OF = "anyOf";
@@ -50,7 +59,7 @@ class RuleTypes {
 
 public class Autoconfigure {
 
-    public static Logger logger = LoggerFactory.getLogger(TDF.class);
+    public static Logger logger = LoggerFactory.getLogger(Autoconfigure.class);
 
     public static class SplitStep {
         public String kas;
@@ -154,7 +163,7 @@ public AttributeValueFQN(String url) throws AutoConfigureException {
             try {
                 URLDecoder.decode(matcher.group(2), StandardCharsets.UTF_8.name());
                 URLDecoder.decode(matcher.group(3), StandardCharsets.UTF_8.name());
-            } catch (Exception e) {
+            } catch (UnsupportedEncodingException | IllegalArgumentException e) {
                 throw new AutoConfigureException("invalid type: error in attribute or value");
             }
 
@@ -166,7 +175,7 @@ public AttributeValueFQN(String url) throws AutoConfigureException {
         public String toString() {
             return url;
         }
-        
+
         public boolean equals(Object obj) {
             if (this == obj) {
                 return true;
@@ -175,7 +184,7 @@ public boolean equals(Object obj) {
                 return false;
             }
             AttributeValueFQN afqn = (AttributeValueFQN) obj;
-            if ((this.url.equals(afqn.url)) && (this.key.equals(afqn.key))){
+            if (this.key.equals(afqn.key)){
                 return true;
             }
             return false;
@@ -185,11 +194,11 @@ public String getKey() {
             return key;
         }
 
-        public String authority() throws AutoConfigureException {
+        public String authority() {
             Pattern pattern = Pattern.compile("^(https?://[\\w./-]+)/attr/\\S*/value/\\S*$");
             Matcher matcher = pattern.matcher(url);
             if (!matcher.find()) {
-                throw new AutoConfigureException("invalid type");
+                throw new RuntimeException("invalid type");
             }
             return matcher.group(1);
         }
@@ -198,34 +207,34 @@ public AttributeNameFQN prefix() throws AutoConfigureException {
             Pattern pattern = Pattern.compile("^(https?://[\\w./-]+/attr/\\S*)/value/\\S*$");
             Matcher matcher = pattern.matcher(url);
             if (!matcher.find()) {
-                throw new AutoConfigureException("invalid type");
+                throw new RuntimeException("invalid type");
             }
             return new AttributeNameFQN(matcher.group(1));
         }
 
-        public String value() throws AutoConfigureException {
+        public String value() {
             Pattern pattern = Pattern.compile("^https?://[\\w./-]+/attr/\\S*/value/(\\S*)$");
             Matcher matcher = pattern.matcher(url);
             if (!matcher.find()) {
-                throw new AutoConfigureException("invalid type");
+                throw new RuntimeException("invalid type");
             }
             try {
                 return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8.name());
-            } catch (Exception e) {
-                throw new AutoConfigureException("invalid type");
+            } catch (UnsupportedEncodingException | IllegalArgumentException e) {
+                throw new RuntimeException("invalid type", e);
             }
         }
 
-        public String name() throws AutoConfigureException {
+        public String name() {
             Pattern pattern = Pattern.compile("^https?://[\\w./-]+/attr/(\\S*)/value/\\S*$");
             Matcher matcher = pattern.matcher(url);
             if (!matcher.find()) {
-                throw new AutoConfigureException("invalid attributeInstance");
+                throw new RuntimeException("invalid attributeInstance");
             }
             try {
                 return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8.name());
-            } catch (Exception e) {
-                throw new AutoConfigureException("invalid attributeInstance");
+            } catch (UnsupportedEncodingException | IllegalArgumentException e) {
+                throw new RuntimeException("invalid attributeInstance", e);
             }
         }
     }
@@ -241,19 +250,14 @@ public KeyAccessGrant(Attribute attr, List<String> kases) {
     }
 
     // Structure capable of generating a split plan from a given set of data tags.
-    public static class Granter {
+    static class Granter {
         private final List<AttributeValueFQN> policy;
-        private Map<String, KeyAccessGrant> grants = new HashMap<>();
+        private final Map<String, KeyAccessGrant> grants = new HashMap<>();
 
         public Granter(List<AttributeValueFQN> policy) {
             this.policy = policy;
         }
 
-        public Granter(List<AttributeValueFQN> policy, Map<String, KeyAccessGrant> grants) {
-            this.policy = policy;
-            this.grants = grants;
-        }
-
         public  Map<String, KeyAccessGrant> getGrants() {
             return grants;
         }
@@ -283,10 +287,6 @@ public KeyAccessGrant byAttribute(AttributeValueFQN fqn) {
             return grants.get(fqn.key);
         }
 
-        public interface StringOperator {
-            public String op();
-        }
-
 
         public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID) throws AutoConfigureException {
             AttributeBooleanExpression b = constructAttributeBoolean();
@@ -296,7 +296,7 @@ public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID
             }
 
             k = k.reduce();
-            int l = k.len();
+            int l = k.size();
             if (l == 0) {
                 // default behavior: split key across all default KAS
                 if (defaultKas.isEmpty()) {
@@ -336,7 +336,7 @@ public BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e)
     
                     List<String> kases = grant.kases;
                     if (kases.isEmpty()) {
-                        kases = List.of(RuleTypes.EMPTY_TERM);
+                        kases = List.of(RuleType.EMPTY_TERM);
                     }
     
                     for (String kas : kases) {
@@ -345,7 +345,7 @@ public BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e)
                 }
     
                 String op = ruleToOperator(clause.def.getRule());
-                if (op == RuleTypes.UNSPECIFIED) {
+                if (op == RuleType.UNSPECIFIED) {
                     logger.warn("Unknown attribute rule type: " + clause);
                 }
     
@@ -361,14 +361,13 @@ AttributeBooleanExpression constructAttributeBoolean() throws AutoConfigureExcep
             List<String> sortedPrefixes = new ArrayList<>();
             for (AttributeValueFQN aP : policy) {
                 AttributeNameFQN a = aP.prefix();
-                String p = a.toString().toLowerCase();
-                SingleAttributeClause clause = prefixes.get(p);
+                SingleAttributeClause clause = prefixes.get(a.getKey());
                 if (clause != null) {
                     clause.values.add(aP);
                 } else if (byAttribute(aP) != null) {
                     var x = new SingleAttributeClause(byAttribute(aP).attr, new ArrayList<AttributeValueFQN>(Arrays.asList(aP)));
-                    prefixes.put(p, x);
-                    sortedPrefixes.add(p);
+                    prefixes.put(a.getKey(), x);
+                    sortedPrefixes.add(a.getKey());
                 }
             }
 
@@ -381,7 +380,7 @@ AttributeBooleanExpression constructAttributeBoolean() throws AutoConfigureExcep
 
 
 
-        public class AttributeMapping {
+        static class AttributeMapping {
 
             private Map<AttributeNameFQN, Attribute> dict;
         
@@ -389,7 +388,7 @@ public AttributeMapping() {
                 this.dict = new HashMap<>();
             }
         
-            public void put(Attribute ad) throws Exception {
+            public void put(Attribute ad) throws AutoConfigureException {
                 if (this.dict == null) {
                     this.dict = new HashMap<>();
                 }
@@ -398,27 +397,27 @@ public void put(Attribute ad) throws Exception {
                 try {
                     prefix = new AttributeNameFQN(ad.getFqn());
                 } catch (Exception e) {
-                    throw new Exception(e);
+                    throw new AutoConfigureException(e);
                 }
         
                 if (this.dict.containsKey(prefix)) {
-                    throw new Exception("Attribute prefix already found: [" + prefix.toString() + "]");
+                    throw new AutoConfigureException("Attribute prefix already found: [" + prefix.toString() + "]");
                 }
         
                 this.dict.put(prefix, ad);
             }
         
-            public Attribute get(AttributeNameFQN prefix) throws Exception {
+            public Attribute get(AttributeNameFQN prefix) throws AutoConfigureException {
                 Attribute ad = this.dict.get(prefix);
                 if (ad == null) {
-                    throw new Exception("Unknown attribute type: [" + prefix.toString() + "], not in [" + this.dict.keySet().toString() + "]");
+                    throw new AutoConfigureException("Unknown attribute type: [" + prefix.toString() + "], not in [" + this.dict.keySet().toString() + "]");
                 }
                 return ad;
             }
 
         }
 
-        public class SingleAttributeClause {
+        static class SingleAttributeClause {
 
             private Attribute def;
             private List<AttributeValueFQN> values;
@@ -429,7 +428,7 @@ public SingleAttributeClause(Attribute def, List<AttributeValueFQN> values) {
             }
         }
 
-        public class AttributeBooleanExpression {
+        class AttributeBooleanExpression {
 
             private List<SingleAttributeClause> must;
         
@@ -461,12 +460,7 @@ public String toString() {
         
                         StringJoiner joiner = new StringJoiner(",");
                         for (AttributeValueFQN v : values) {
-                            try {
-                                joiner.add(v.value());
-                            } catch (AutoConfigureException e) {
-                                // This shouldnt happen
-                                joiner.add("");
-                            }
+                            joiner.add(v.value());
                         }
                         sb.append(joiner.toString());
                         sb.append("}");
@@ -504,8 +498,8 @@ public KeyClause(String operator, List<PublicKeyInfo> values) {
         
             @Override
             public String toString() {
-                if (values.size() == 1 && values.get(0).getKas().equals(RuleTypes.EMPTY_TERM)) {
-                    return "[" + RuleTypes.EMPTY_TERM + "]";
+                if (values.size() == 1 && values.get(0).getKas().equals(RuleType.EMPTY_TERM)) {
+                    return "[" + RuleType.EMPTY_TERM + "]";
                 }
                 if (values.size() == 1) {
                     return "(" + values.get(0).getKas() + ")";
@@ -514,7 +508,7 @@ public String toString() {
                 StringBuilder sb = new StringBuilder();
                 sb.append("(");
                 String op = "⋀";
-                if (operator.equals(RuleTypes.ANY_OF)) {
+                if (operator.equals(RuleType.ANY_OF)) {
                     op = "⋁";
                 }
         
@@ -549,7 +543,7 @@ public String toString() {
                 return sb.toString();
             }
 
-            public int len() {
+            public int size() {
                 int count = 0;
                 for (KeyClause v : values) {
                     count += v.values.size();
@@ -560,14 +554,14 @@ public int len() {
             public BooleanKeyExpression reduce() {
                 List<Disjunction> conjunction = new ArrayList<>();
                 for (KeyClause v : values) {
-                    if (v.operator.equals(RuleTypes.ANY_OF)) {
+                    if (v.operator.equals(RuleType.ANY_OF)) {
                         Disjunction terms = sortedNoDupes(v.values);
                         if (!terms.isEmpty() && !within(conjunction, terms)) {
                             conjunction.add(terms);
                         }
                     } else {
                         for (PublicKeyInfo k : v.values) {
-                            if (k.getKas().equals(RuleTypes.EMPTY_TERM)) {
+                            if (k.getKas().equals(RuleType.EMPTY_TERM)) {
                                 continue;
                             }
                             Disjunction terms = new Disjunction();
@@ -588,7 +582,7 @@ public BooleanKeyExpression reduce() {
                     for (String k : d) {
                         pki.add(new PublicKeyInfo(k));
                     }
-                    newValues.add(new KeyClause(RuleTypes.ANY_OF, pki));
+                    newValues.add(new KeyClause(RuleType.ANY_OF, pki));
                 }
                 return new BooleanKeyExpression(newValues);
             }
@@ -599,7 +593,7 @@ public Disjunction sortedNoDupes(List<PublicKeyInfo> l) {
     
                 for (PublicKeyInfo e : l) {
                     String kas = e.getKas();
-                    if (!kas.equals(RuleTypes.EMPTY_TERM) && !set.contains(kas)) {
+                    if (!kas.equals(RuleType.EMPTY_TERM) && !set.contains(kas)) {
                         set.add(kas);
                         list.add(kas);
                     }
@@ -689,7 +683,7 @@ public static Granter newGranterFromService(SDK.AttributesService as, AttributeV
         GetAttributeValuesByFqnsResponse av;
         av = as.getAttributeValuesByFqn(request);
 
-        Granter grants = new Granter(Arrays.asList(fqns), new HashMap<>());
+        Granter grants = new Granter(Arrays.asList(fqns));
 
         for (Map.Entry<String,GetAttributeValuesByFqnsResponse.AttributeAndValue> entry : av.getFqnAttributeValuesMap().entrySet()) {
             String fqnstr = entry.getKey();
@@ -721,9 +715,8 @@ public static Granter newGranterFromService(SDK.AttributesService as, AttributeV
     // Unlike `NewGranterFromService`, this works offline.
     public static Granter newGranterFromAttributes(Value... attrs) throws AutoConfigureException {
         List<AttributeValueFQN> policyList = new ArrayList<>(attrs.length);
-        Map<String, KeyAccessGrant> grantsMap = new HashMap<>();
 
-        Granter grants = new Granter(policyList, grantsMap);
+        Granter grants = new Granter(policyList);
 
         for (Value v : attrs) {
             AttributeValueFQN fqn;

From d337c920db8d857a61546fa1e1074bb90fa02d57 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 20 Aug 2024 13:38:45 -0400
Subject: [PATCH 17/23] resolving more suggestions

---
 .../platform/sdk/AutoConfigureException.java  | 15 +++++++++++
 .../opentdf/platform/sdk/Autoconfigure.java   | 27 +++----------------
 .../platform/sdk/AutoconfigureTest.java       | 17 +++++-------
 3 files changed, 24 insertions(+), 35 deletions(-)
 create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/AutoConfigureException.java

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AutoConfigureException.java b/sdk/src/main/java/io/opentdf/platform/sdk/AutoConfigureException.java
new file mode 100644
index 00000000..c134806c
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/AutoConfigureException.java
@@ -0,0 +1,15 @@
+package io.opentdf.platform.sdk;
+
+public class AutoConfigureException extends RuntimeException  {
+    public AutoConfigureException(String message) {
+        super(message);
+    }
+
+    public AutoConfigureException(Exception e) {
+        super(e);
+    }
+
+    public AutoConfigureException(String message, Exception e) {
+        super(message, e);
+    }
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index 11f5e5cf..716c3f2e 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -1,6 +1,5 @@
 package io.opentdf.platform.sdk;
 
-import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
@@ -32,21 +31,6 @@
 import io.opentdf.platform.policy.AttributeValueSelector;
 import io.opentdf.platform.policy.AttributeRuleTypeEnum;
 
-// Error handling class
-class AutoConfigureException extends IOException {
-    public AutoConfigureException(String message) {
-        super(message);
-    }
-
-    public AutoConfigureException(Exception e) {
-        super(e);
-    }
-
-    public AutoConfigureException(String message, Exception e) {
-        super(message, e);
-    }
-}
-
 // Attribute rule types: operators!
 class RuleType {
     public static final String HIERARCHY = "hierarchy";
@@ -259,7 +243,7 @@ public Granter(List<AttributeValueFQN> policy) {
         }
 
         public  Map<String, KeyAccessGrant> getGrants() {
-            return grants;
+            return new HashMap<String,KeyAccessGrant>(grants);
         }
 
         public List<AttributeValueFQN> getPolicy() {
@@ -322,7 +306,7 @@ public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID
             return steps;
         }
 
-        public BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e) throws AutoConfigureException {
+        BooleanKeyExpression insertKeysForAttribute(AttributeBooleanExpression e) throws AutoConfigureException {
             List<KeyClause> kcs = new ArrayList<>(e.must.size());
     
             for (SingleAttributeClause clause : e.must) {
@@ -393,12 +377,7 @@ public void put(Attribute ad) throws AutoConfigureException {
                     this.dict = new HashMap<>();
                 }
         
-                AttributeNameFQN prefix;
-                try {
-                    prefix = new AttributeNameFQN(ad.getFqn());
-                } catch (Exception e) {
-                    throw new AutoConfigureException(e);
-                }
+                AttributeNameFQN prefix = new AttributeNameFQN(ad.getFqn());
         
                 if (this.dict.containsKey(prefix)) {
                     throw new AutoConfigureException("Attribute prefix already found: [" + prefix.toString() + "]");
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
index f148e148..1ac8a486 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
@@ -18,6 +18,7 @@
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -109,17 +110,11 @@ private static String spongeCase(String s) {
     }
 
     private List<Value> valuesToPolicy(AttributeValueFQN... p) throws AutoConfigureException {
-        return List.of(p).stream()
-            .map(t -> {
-                try {
-                    return mockValueFor(t);
-                } catch (AutoConfigureException e) {
-                    // TODO Auto-generated catch block
-                    e.printStackTrace();
-                    return Value.newBuilder().build();
-                }
-            })
-            .collect(Collectors.toList());
+        List<Value> values = new ArrayList<>();
+        for (AttributeValueFQN afqn : List.of(p)){
+            values.add(mockValueFor(afqn));
+        }
+        return values;
     }
 
     private List<String> policyToStringKeys(List<AttributeValueFQN> policy) {

From 1ac7a2012a994141318ad5f3467bd7b73d25a932 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 20 Aug 2024 13:48:51 -0400
Subject: [PATCH 18/23] hashcode

---
 .../io/opentdf/platform/sdk/Autoconfigure.java | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index 716c3f2e..9aac8f88 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -11,6 +11,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.StringJoiner;
 import java.util.regex.Matcher;
@@ -54,6 +55,7 @@ public SplitStep(String kas, String splitId) {
             this.splitID = splitId;
         }
 
+        @Override
         public boolean equals(Object obj) {
             if (this == obj) {
                 return true;
@@ -67,6 +69,11 @@ public boolean equals(Object obj) {
             }
             return false;
         }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(kas, splitID);
+        }
     }
 
     // Utility class for an attribute name FQN.
@@ -160,6 +167,7 @@ public String toString() {
             return url;
         }
 
+        @Override
         public boolean equals(Object obj) {
             if (this == obj) {
                 return true;
@@ -174,6 +182,11 @@ public boolean equals(Object obj) {
             return false;
         }
 
+        @Override
+        public int hashCode() {
+            return Objects.hash(key);
+        }
+
         public String getKey() {
             return key;
         }
@@ -619,6 +632,11 @@ public boolean equals(Object obj) {
                 return true;
             }
 
+            @Override
+            public int hashCode() {
+                return super.hashCode();
+            }
+
         }
 
         public static boolean within(List<Disjunction> list, Disjunction e) {

From 06aedff6320ae2d7b9374a32487e03fd65a3496f Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Tue, 20 Aug 2024 16:19:20 -0400
Subject: [PATCH 19/23] reverting attributes client change

---
 .../java/io/opentdf/platform/Command.java     |  3 +-
 sdk/pom.xml                                   |  8 +++
 .../opentdf/platform/sdk/Autoconfigure.java   |  6 +-
 .../java/io/opentdf/platform/sdk/TDF.java     |  5 +-
 .../java/io/opentdf/platform/sdk/TDFTest.java | 65 ++++++++++++++-----
 5 files changed, 66 insertions(+), 21 deletions(-)

diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java
index 2e358f02..ef81102c 100644
--- a/cmdline/src/main/java/io/opentdf/platform/Command.java
+++ b/cmdline/src/main/java/io/opentdf/platform/Command.java
@@ -28,6 +28,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.ExecutionException;
 import java.util.function.Consumer;
 
 @CommandLine.Command(name = "tdf")
@@ -54,7 +55,7 @@ void encrypt(
             @Option(names = {"-a", "--attr"}, defaultValue = Option.NULL_VALUE) Optional<String> attributes,
             @Option(names = {"-c", "--autoconfigure"}, defaultValue = Option.NULL_VALUE) Optional<Boolean> autoconfigure,
             @Option(names = {"--mime-type"}, defaultValue = Option.NULL_VALUE) Optional<String> mimeType) throws
-            IOException, JOSEException {
+            IOException, JOSEException, AutoConfigureException, InterruptedException, ExecutionException {
 
         var sdk = buildSDK();
         var kasInfos = kas.stream().map(k -> {
diff --git a/sdk/pom.xml b/sdk/pom.xml
index 2f8e5d69..9022141d 100644
--- a/sdk/pom.xml
+++ b/sdk/pom.xml
@@ -127,5 +127,13 @@
             <artifactId>java-json-canonicalization</artifactId>
             <version>1.1</version>
         </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-junit-jupiter</artifactId>
+        </dependency>
     </dependencies>
 </project>
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index 9aac8f88..6db748ca 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -14,6 +14,7 @@
 import java.util.Objects;
 import java.util.Set;
 import java.util.StringJoiner;
+import java.util.concurrent.ExecutionException;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -24,6 +25,7 @@
 import com.google.common.base.Supplier;
 
 import io.opentdf.platform.policy.KeyAccessServer;
+import io.opentdf.platform.policy.attributes.AttributesServiceGrpc.AttributesServiceFutureStub;
 import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
 import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
 import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse.AttributeAndValue;
@@ -666,7 +668,7 @@ public static String ruleToOperator(AttributeRuleTypeEnum e) {
     }
 
     // Gets a list of directory of KAS grants for a list of attribute FQNs
-    public static Granter newGranterFromService(SDK.AttributesService as, AttributeValueFQN... fqns) throws AutoConfigureException {
+    public static Granter newGranterFromService(AttributesServiceFutureStub as, AttributeValueFQN... fqns) throws AutoConfigureException, InterruptedException, ExecutionException {
         String[] fqnsStr = new String[fqns.length];
         for (int i = 0; i < fqns.length; i++) {
             fqnsStr[i] = fqns[i].toString();
@@ -678,7 +680,7 @@ public static Granter newGranterFromService(SDK.AttributesService as, AttributeV
             .build();
 
         GetAttributeValuesByFqnsResponse av;
-        av = as.getAttributeValuesByFqn(request);
+        av = as.getAttributeValuesByFqns(request).get();
 
         Granter grants = new Granter(Arrays.asList(fqns));
 
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index 8b8b98fc..d83b5cdd 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -9,7 +9,7 @@
 import com.nimbusds.jwt.SignedJWT;
 
 import io.opentdf.platform.policy.Value;
-
+import io.opentdf.platform.policy.attributes.AttributesServiceGrpc.AttributesServiceFutureStub;
 import io.opentdf.platform.sdk.Config.TDFConfig;
 import io.opentdf.platform.sdk.Autoconfigure.AttributeValueFQN;
 import io.opentdf.platform.sdk.Config.KASInfo;
@@ -32,6 +32,7 @@
 import java.util.function.Consumer;
 import java.text.ParseException;
 import java.util.*;
+import java.util.concurrent.ExecutionException;
 
 public class TDF {
 
@@ -376,7 +377,7 @@ private static String calculateSignature(byte[] data, byte[] secret, Config.Inte
 
     public TDFObject createTDF(InputStream payload,
                                OutputStream outputStream,
-                               Config.TDFConfig tdfConfig, SDK.KAS kas, SDK.AttributesService attrService) throws IOException, JOSEException {
+                               Config.TDFConfig tdfConfig, SDK.KAS kas, AttributesServiceFutureStub attrService) throws IOException, JOSEException, AutoConfigureException, InterruptedException, ExecutionException {
 
         if (tdfConfig.autoconfigure) {
             Autoconfigure.Granter granter = new Autoconfigure.Granter(new ArrayList<>());
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
index ab773673..f2e8b069 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
@@ -1,6 +1,7 @@
 package io.opentdf.platform.sdk;
 
 
+import com.google.common.util.concurrent.ListenableFuture;
 import com.nimbusds.jose.crypto.MACSigner;
 import com.nimbusds.jose.crypto.MACVerifier;
 import com.nimbusds.jose.crypto.RSASSASigner;
@@ -9,9 +10,12 @@
 
 import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
 import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
+import io.opentdf.platform.policy.attributes.AttributesServiceGrpc;
+import io.opentdf.platform.policy.attributes.AttributesServiceGrpc.AttributesServiceFutureStub;
 import io.opentdf.platform.sdk.nanotdf.NanoTDFType;
 import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
 import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import javax.annotation.Nonnull;
@@ -28,11 +32,20 @@
 import java.util.Random;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+
 import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 public class TDFTest {
 
+    @BeforeEach
+    public void setup() {
+        attributeGrpcStub = mock(AttributesServiceGrpc.AttributesServiceFutureStub.class);
+    }
+
     private static SDK.KAS kas = new SDK.KAS() {
         @Override
         public void close() {}
@@ -71,16 +84,9 @@ public byte[] unwrapNanoTDF(NanoTDFType.ECCurve curve, String header, String kas
             return null;
         }
     };
-    private static SDK.AttributesService attrServ = new SDK.AttributesService() {
-        @Override
-        public void close() {}
-
-        @Override
-        public GetAttributeValuesByFqnsResponse getAttributeValuesByFqn(GetAttributeValuesByFqnsRequest request) {
-            return GetAttributeValuesByFqnsResponse.newBuilder().build();
-        }
-    };
 
+    AttributesServiceGrpc.AttributesServiceFutureStub attributeGrpcStub;
+    
     private static ArrayList<KeyPair> keypairs = new ArrayList<>();
 
     @BeforeAll
@@ -92,6 +98,11 @@ static void createKeypairs() {
 
     @Test
     void testSimpleTDFEncryptAndDecrypt() throws Exception {
+
+        ListenableFuture<GetAttributeValuesByFqnsResponse> resp1 = mock(ListenableFuture.class);
+        lenient().when(resp1.get()).thenReturn(GetAttributeValuesByFqnsResponse.newBuilder().build());
+        lenient().when(attributeGrpcStub.getAttributeValuesByFqns(any(GetAttributeValuesByFqnsRequest.class))).thenReturn(resp1);
+
         SecureRandom secureRandom = new SecureRandom();
         byte[] key = new byte[32];
         secureRandom.nextBytes(key);
@@ -119,7 +130,7 @@ void testSimpleTDFEncryptAndDecrypt() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attributeGrpcStub);
 
         var assertionVerificationKeys = new Config.AssertionVerificationKeys();
         assertionVerificationKeys.defaultKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, key);
@@ -139,6 +150,10 @@ void testSimpleTDFEncryptAndDecrypt() throws Exception {
     @Test
     void testSimpleTDFWithAssertionWithRS256() throws Exception {
 
+        ListenableFuture<GetAttributeValuesByFqnsResponse> resp1 = mock(ListenableFuture.class);
+        lenient().when(resp1.get()).thenReturn(GetAttributeValuesByFqnsResponse.newBuilder().build());
+        lenient().when(attributeGrpcStub.getAttributeValuesByFqns(any(GetAttributeValuesByFqnsRequest.class))).thenReturn(resp1);
+
         String assertion1Id = "assertion1";
         var keypair = CryptoUtils.generateRSAKeypair();
         var assertionConfig = new AssertionConfig();
@@ -164,7 +179,7 @@ void testSimpleTDFWithAssertionWithRS256() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attributeGrpcStub);
 
         var assertionVerificationKeys = new Config.AssertionVerificationKeys();
         assertionVerificationKeys.keys.put(assertion1Id,
@@ -182,6 +197,10 @@ void testSimpleTDFWithAssertionWithRS256() throws Exception {
     @Test
     void testSimpleTDFWithAssertionWithHS256() throws Exception {
 
+        ListenableFuture<GetAttributeValuesByFqnsResponse> resp1 = mock(ListenableFuture.class);
+        lenient().when(resp1.get()).thenReturn(GetAttributeValuesByFqnsResponse.newBuilder().build());
+        lenient().when(attributeGrpcStub.getAttributeValuesByFqns(any(GetAttributeValuesByFqnsRequest.class))).thenReturn(resp1);
+
         String assertion1Id = "assertion1";
         var assertionConfig1 = new AssertionConfig();
         assertionConfig1.id = assertion1Id;
@@ -204,7 +223,7 @@ void testSimpleTDFWithAssertionWithHS256() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attributeGrpcStub);
 
         var unwrappedData = new ByteArrayOutputStream();
         var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), kas);
@@ -217,6 +236,11 @@ void testSimpleTDFWithAssertionWithHS256() throws Exception {
 
     @Test
     public void testCreatingTDFWithMultipleSegments() throws Exception {
+
+        ListenableFuture<GetAttributeValuesByFqnsResponse> resp1 = mock(ListenableFuture.class);
+        lenient().when(resp1.get()).thenReturn(GetAttributeValuesByFqnsResponse.newBuilder().build());
+        lenient().when(attributeGrpcStub.getAttributeValuesByFqns(any(GetAttributeValuesByFqnsRequest.class))).thenReturn(resp1);
+
         var random = new Random();
 
         Config.TDFConfig config = Config.newTDFConfig(
@@ -232,7 +256,7 @@ public void testCreatingTDFWithMultipleSegments() throws Exception {
         var plainTextInputStream = new ByteArrayInputStream(data);
         var tdfOutputStream = new ByteArrayOutputStream();
         var tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attributeGrpcStub);
         var unwrappedData = new ByteArrayOutputStream();
         var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), kas);
         reader.readPayload(unwrappedData);
@@ -244,7 +268,12 @@ public void testCreatingTDFWithMultipleSegments() throws Exception {
     }
 
     @Test
-    public void testCreatingTooLargeTDF() {
+    public void testCreatingTooLargeTDF() throws Exception {
+        ListenableFuture<GetAttributeValuesByFqnsResponse> resp1 = mock(ListenableFuture.class);
+        lenient().when(resp1.get()).thenReturn(GetAttributeValuesByFqnsResponse.newBuilder().build());
+        lenient().when(attributeGrpcStub.getAttributeValuesByFqns(any(GetAttributeValuesByFqnsRequest.class))).thenReturn(resp1);
+
+
         var random = new Random();
         var maxSize = random.nextInt(1024);
         var numReturned = new AtomicInteger(0);
@@ -281,7 +310,7 @@ public void write(byte[] b, int off, int len) {}
                 Config.withKasInformation(getKASInfos()),
                 Config.withSegmentSize(1 + random.nextInt(128)));
         assertThrows(TDF.DataSizeNotSupported.class,
-                () -> tdf.createTDF(is, os, tdfConfig, kas, attrServ),
+                () -> tdf.createTDF(is, os, tdfConfig, kas, attributeGrpcStub),
                 "didn't throw an exception when we created TDF that was too large");
         assertThat(numReturned.get())
                 .withFailMessage("test returned the wrong number of bytes")
@@ -291,6 +320,10 @@ public void write(byte[] b, int off, int len) {}
     @Test
     public void testCreateTDFWithMimeType() throws Exception {
 
+        ListenableFuture<GetAttributeValuesByFqnsResponse> resp1 = mock(ListenableFuture.class);
+        lenient().when(resp1.get()).thenReturn(GetAttributeValuesByFqnsResponse.newBuilder().build());
+        lenient().when(attributeGrpcStub.getAttributeValuesByFqns(any(GetAttributeValuesByFqnsRequest.class))).thenReturn(resp1);
+
         final String mimeType = "application/pdf";
 
         Config.TDFConfig config = Config.newTDFConfig(
@@ -304,7 +337,7 @@ public void testCreateTDFWithMimeType() throws Exception {
         ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream();
 
         TDF tdf = new TDF();
-        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attrServ);
+        tdf.createTDF(plainTextInputStream, tdfOutputStream, config, kas, attributeGrpcStub);
 
         var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), kas);
         assertThat(reader.getManifest().payload.mimeType).isEqualTo(mimeType);

From ac005f9d3cd4f9e2bbae962eb80a2eab48e3ec2e Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Wed, 21 Aug 2024 12:45:20 -0400
Subject: [PATCH 20/23] adding key cache

---
 .github/workflows/checks.yaml                 |   2 -
 .../opentdf/platform/sdk/Autoconfigure.java   |  20 +--
 .../java/io/opentdf/platform/sdk/Config.java  |   5 +-
 .../io/opentdf/platform/sdk/KASClient.java    | 116 ++++++++++++++++--
 .../java/io/opentdf/platform/sdk/SDK.java     |   3 +-
 .../java/io/opentdf/platform/sdk/TDF.java     |  20 +--
 .../platform/sdk/AutoconfigureTest.java       |  85 ++++++-------
 .../opentdf/platform/sdk/KASClientTest.java   |   4 +-
 .../io/opentdf/platform/sdk/NanoTDFTest.java  |  11 +-
 .../java/io/opentdf/platform/sdk/TDFTest.java |  14 +--
 10 files changed, 185 insertions(+), 95 deletions(-)

diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index 59069682..947833fe 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -2,8 +2,6 @@ name: "Checks"
 
 on:
   pull_request:
-    branches:
-      - main
   push:
     branches:
       - main
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index 6db748ca..476c5439 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -48,11 +48,11 @@ public class Autoconfigure {
 
     public static Logger logger = LoggerFactory.getLogger(Autoconfigure.class);
 
-    public static class SplitStep {
+    public static class KeySplitStep {
         public String kas;
         public String splitID;
 
-        public SplitStep(String kas, String splitId) {
+        public KeySplitStep(String kas, String splitId) {
             this.kas = kas;
             this.splitID = splitId;
         }
@@ -62,10 +62,10 @@ public boolean equals(Object obj) {
             if (this == obj) {
                 return true;
             }
-            if (obj == null || !(obj instanceof SplitStep)) {
+            if (obj == null || !(obj instanceof KeySplitStep)) {
                 return false;
             }
-            SplitStep ss = (SplitStep) obj;
+            KeySplitStep ss = (KeySplitStep) obj;
             if ((this.kas.equals(ss.kas)) && (this.splitID.equals(ss.splitID))){
                 return true;
             }
@@ -287,7 +287,7 @@ public KeyAccessGrant byAttribute(AttributeValueFQN fqn) {
         }
 
 
-        public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID) throws AutoConfigureException {
+        public List<KeySplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID) throws AutoConfigureException {
             AttributeBooleanExpression b = constructAttributeBoolean();
             BooleanKeyExpression k = insertKeysForAttribute(b);
             if (k == null) {
@@ -301,21 +301,21 @@ public List<SplitStep> plan(List<String> defaultKas, Supplier<String> genSplitID
                 if (defaultKas.isEmpty()) {
                     throw new AutoConfigureException("no default KAS specified; required for grantless plans");
                 } else if (defaultKas.size() == 1) {
-                    return Collections.singletonList(new SplitStep(defaultKas.get(0), ""));
+                    return Collections.singletonList(new KeySplitStep(defaultKas.get(0), ""));
                 } else {
-                    List<SplitStep> result = new ArrayList<>();
+                    List<KeySplitStep> result = new ArrayList<>();
                     for (String kas : defaultKas) {
-                        result.add(new SplitStep(kas, genSplitID.get()));
+                        result.add(new KeySplitStep(kas, genSplitID.get()));
                     }
                     return result;
                 }
             }
 
-            List<SplitStep> steps = new ArrayList<>();
+            List<KeySplitStep> steps = new ArrayList<>();
             for (KeyClause v : k.values) {
                 String splitID = (l > 1) ? genSplitID.get() : "";
                 for (PublicKeyInfo o : v.values) {
-                    steps.add(new SplitStep(o.kas, splitID));
+                    steps.add(new KeySplitStep(o.kas, splitID));
                 }
             }
             return steps;
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
index a7a081c9..ea4fbac5 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
@@ -34,6 +34,7 @@ public static class KASInfo {
         public String PublicKey;
         public String KID;
         public Boolean Default;
+        public String Algorithm;
     }
 
 
@@ -88,7 +89,7 @@ public static class TDFConfig {
         public List<KASInfo> kasInfoList;
         public List<io.opentdf.platform.sdk.AssertionConfig> assertionConfigList;
         public String mimeType;
-        public List<Autoconfigure.SplitStep> splitPlan;
+        public List<Autoconfigure.KeySplitStep> splitPlan;
 
         public TDFConfig() {
             this.autoconfigure = true;
@@ -163,7 +164,7 @@ public static Consumer<TDFConfig> withKasInformation(KASInfo... kasInfoList) {
         };
     }
 
-    public static Consumer<TDFConfig> withSplitPlan(Autoconfigure.SplitStep... p) {
+    public static Consumer<TDFConfig> withSplitPlan(Autoconfigure.KeySplitStep... p) {
         return (TDFConfig config) -> {
             config.splitPlan = new ArrayList<>(Arrays.asList(p));
             config.autoconfigure = false;
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java
index 2eddec57..9d2f0c84 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java
@@ -12,7 +12,9 @@
 import io.grpc.ManagedChannel;
 import io.opentdf.platform.kas.AccessServiceGrpc;
 import io.opentdf.platform.kas.PublicKeyRequest;
+import io.opentdf.platform.kas.PublicKeyResponse;
 import io.opentdf.platform.kas.RewrapRequest;
+import io.opentdf.platform.sdk.Autoconfigure.KeySplitStep;
 import io.opentdf.platform.sdk.nanotdf.ECKeyPair;
 import io.opentdf.platform.sdk.nanotdf.NanoTDFType;
 
@@ -22,9 +24,12 @@
 import java.net.URL;
 import java.time.Duration;
 import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.Map;
 import java.util.function.Function;
 
 import static java.lang.String.format;
@@ -36,6 +41,8 @@ public class KASClient implements SDK.KAS, AutoCloseable {
     private final AsymDecryption decryptor;
     private final String publicKeyPEM;
 
+    private KASKeyCache kasKeyCache;
+
     /***
      * A client that communicates with KAS
      * @param channelFactory A function that produces channels that can be used to communicate
@@ -51,28 +58,41 @@ public KASClient(Function <String, ManagedChannel> channelFactory, RSAKey dpopKe
         var encryptionKeypair = CryptoUtils.generateRSAKeypair();
         decryptor = new AsymDecryption(encryptionKeypair.getPrivate());
         publicKeyPEM = CryptoUtils.getRSAPublicKeyPEM(encryptionKeypair.getPublic());
+
+        this.kasKeyCache = new KASKeyCache();
     }
 
     @Override
     public String getECPublicKey(Config.KASInfo kasInfo, NanoTDFType.ECCurve curve) {
+        //String.format("ec:%s", curve.toString())
         return getStub(kasInfo.URL)
                 .publicKey(PublicKeyRequest.newBuilder().setAlgorithm(String.format("ec:%s", curve.toString())).build())
                 .getPublicKey();
     }
 
     @Override
-    public String getPublicKey(Config.KASInfo kasInfo) {
-        return getStub(kasInfo.URL)
-                .publicKey(PublicKeyRequest.getDefaultInstance())
-                .getPublicKey();
+    public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) {
+        Config.KASInfo cachedValue = this.kasKeyCache.get(kasInfo.URL, kasInfo.Algorithm);
+        if (cachedValue != null) {
+            return cachedValue;
+        }
+        PublicKeyResponse resp = getStub(kasInfo.URL).publicKey(PublicKeyRequest.getDefaultInstance());
+        
+        var kiCopy = new Config.KASInfo();
+        kiCopy.KID = resp.getKid();
+        kiCopy.PublicKey = resp.getPublicKey();
+        kiCopy.URL = kasInfo.URL;
+
+        this.kasKeyCache.store(kiCopy);
+        return kiCopy;
     }
 
-    @Override
-    public String getKid(Config.KASInfo kasInfo) {
-        return getStub(kasInfo.URL)
-                .publicKey(PublicKeyRequest.getDefaultInstance())
-                .getKid();
-    }
+    // @Override
+    // public String getKid(Config.KASInfo kasInfo) {
+    //     return getStub(kasInfo.URL)
+    //             .publicKey(PublicKeyRequest.getDefaultInstance())
+    //             .getKid();
+    // }
 
     private String normalizeAddress(String urlString) {
         URL url;
@@ -110,6 +130,82 @@ public synchronized void close() {
         }
     }
 
+    class TimeStampedKASInfo {
+        Config.KASInfo kasInfo;
+        LocalDateTime timestamp;
+
+        public TimeStampedKASInfo(Config.KASInfo kasInfo, LocalDateTime timestamp) {
+            this.kasInfo = kasInfo;
+            this.timestamp = timestamp;
+        }
+    }
+
+    class KASKeyRequest {
+        private String url;
+        private String algorithm;
+    
+        public KASKeyRequest(String url, String algorithm) {
+            this.url = url;
+            this.algorithm = algorithm;
+        }
+    
+        // Override equals and hashCode to ensure proper functioning of the HashMap
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || !(o instanceof KASKeyRequest)) return false;
+            KASKeyRequest that = (KASKeyRequest) o;
+           if (algorithm == null){
+                return url.equals(that.url);
+            }
+            return url.equals(that.url) && algorithm.equals(that.algorithm);
+        }
+    
+        @Override
+        public int hashCode() {
+            int result = 31 * url.hashCode();
+            if (algorithm != null) {
+                result = result + algorithm.hashCode();
+            }
+            return result;
+        }
+    }
+
+    class KASKeyCache {
+        private Map<KASKeyRequest, TimeStampedKASInfo> cache;
+
+        public KASKeyCache() {
+            this.cache = new HashMap<>();
+        }
+
+        public void clear() {
+            this.cache = new HashMap<>();
+        }
+
+        public Config.KASInfo get(String url, String algorithm) {
+            KASKeyRequest cacheKey = new KASKeyRequest(url, algorithm);
+            LocalDateTime now = LocalDateTime.now();
+            TimeStampedKASInfo cachedValue = cache.get(cacheKey);
+
+            if (cachedValue == null) {
+                return null;
+            }
+
+            LocalDateTime anHourAgo = now.minus(1, ChronoUnit.HOURS);
+            if (anHourAgo.isAfter(cachedValue.timestamp)) {
+                cache.remove(cacheKey);
+                return null;
+            }
+
+            return cachedValue.kasInfo;
+        }
+
+        public void store(Config.KASInfo kasInfo) {
+            KASKeyRequest cacheKey = new KASKeyRequest(kasInfo.URL, kasInfo.Algorithm);
+            cache.put(cacheKey, new TimeStampedKASInfo(kasInfo, LocalDateTime.now()));
+        }
+    }
+
     static class RewrapRequestBody {
         String policy;
         String clientPublicKey;
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 426b6c3b..a1b72689 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
@@ -38,8 +38,7 @@ public void close() throws Exception {
     }
 
     public interface KAS extends AutoCloseable {
-        String getPublicKey(Config.KASInfo kasInfo);
-        String getKid(Config.KASInfo kasInfo);
+        Config.KASInfo getPublicKey(Config.KASInfo kasInfo);
         String getECPublicKey(Config.KASInfo kasInfo, NanoTDFType.ECCurve curve);
         byte[] unwrap(Manifest.KeyAccess keyAccess, String policy);
         byte[] unwrapNanoTDF(NanoTDFType.ECCurve curve, String header, String kasURL);
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index d83b5cdd..3c586048 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -181,10 +181,10 @@ private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
             Map<String, Config.KASInfo> latestKASInfo = new HashMap<>();
             if (tdfConfig.splitPlan == null || tdfConfig.splitPlan.isEmpty()) {
                 // Default split plan: Split keys across all KASes
-                List<Autoconfigure.SplitStep> splitPlan = new ArrayList<>(tdfConfig.kasInfoList.size());
+                List<Autoconfigure.KeySplitStep> splitPlan = new ArrayList<>(tdfConfig.kasInfoList.size());
                 int i = 0;
                 for (Config.KASInfo kasInfo : tdfConfig.kasInfoList) {
-                    Autoconfigure.SplitStep step = new Autoconfigure.SplitStep(kasInfo.URL, "");
+                    Autoconfigure.KeySplitStep step = new Autoconfigure.KeySplitStep(kasInfo.URL, "");
                     if (tdfConfig.kasInfoList.size() > 1) {
                         step.splitID = String.format("s-%d", i++);
                     }
@@ -207,7 +207,7 @@ private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
             Map<String, List<Config.KASInfo>> conjunction = new HashMap<>();
             List<String> splitIDs = new ArrayList<>();
 
-            for (Autoconfigure.SplitStep splitInfo : tdfConfig.splitPlan) {
+            for (Autoconfigure.KeySplitStep splitInfo : tdfConfig.splitPlan) {
                 // Public key was passed in with kasInfoList
                 // TODO First look up in attribute information / add to split plan?
                 Config.KASInfo ki = latestKASInfo.get(splitInfo.kas);
@@ -215,8 +215,7 @@ private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
                     logger.info("no public key provided for KAS at {}, retrieving", splitInfo.kas);
                     var getKI = new Config.KASInfo();
                     getKI.URL = splitInfo.kas;
-                    getKI.PublicKey = kas.getPublicKey(getKI);
-                    getKI.KID = kas.getKid(getKI);
+                    getKI = kas.getPublicKey(getKI);
                     latestKASInfo.put(splitInfo.kas, getKI);
                     ki = getKI;
                 }
@@ -547,8 +546,9 @@ private void fillInPublicKeyInfo(List<Config.KASInfo> kasInfoList, SDK.KAS kas)
                 continue;
             }
             logger.info("no public key provided for KAS at {}, retrieving", kasInfo.URL);
-            kasInfo.PublicKey = kas.getPublicKey(kasInfo);
-            kasInfo.KID = kas.getKid(kasInfo);
+            Config.KASInfo getKasInfo = kas.getPublicKey(kasInfo);
+            kasInfo.PublicKey = getKasInfo.PublicKey;
+            kasInfo.KID = getKasInfo.KID;
         }
     }
 
@@ -563,7 +563,7 @@ public Reader loadTDF(SeekableByteChannel tdf, SDK.KAS kas, Config.AssertionVeri
         
         Set<String> knownSplits = new HashSet<String>();
         Set<String> foundSplits = new HashSet<String>();;
-        Map<Autoconfigure.SplitStep, Exception> skippedSplits = new HashMap<>();
+        Map<Autoconfigure.KeySplitStep, Exception> skippedSplits = new HashMap<>();
         boolean mixedSplits = manifest.encryptionInformation.keyAccessObj.size() > 1 &&
          (manifest.encryptionInformation.keyAccessObj.get(0).sid != null) &&
           !manifest.encryptionInformation.keyAccessObj.get(0).sid.isEmpty();
@@ -572,7 +572,7 @@ public Reader loadTDF(SeekableByteChannel tdf, SDK.KAS kas, Config.AssertionVeri
 
         if (manifest.payload.isEncrypted) {
             for (Manifest.KeyAccess keyAccess: manifest.encryptionInformation.keyAccessObj) {
-                Autoconfigure.SplitStep ss = new Autoconfigure.SplitStep(keyAccess.url, keyAccess.sid);
+                Autoconfigure.KeySplitStep ss = new Autoconfigure.KeySplitStep(keyAccess.url, keyAccess.sid);
                 byte[] unwrappedKey;
                 if (!mixedSplits) {
                     unwrappedKey = kas.unwrap(keyAccess, manifest.encryptionInformation.policy);
@@ -615,7 +615,7 @@ public Reader loadTDF(SeekableByteChannel tdf, SDK.KAS kas, Config.AssertionVeri
                 List<Exception> exceptionList = new ArrayList<>(skippedSplits.size() + 1);
                 exceptionList.add(new Exception("splitKey.unable to reconstruct split key: " + skippedSplits));
                 
-                for (Map.Entry<Autoconfigure.SplitStep, Exception> entry : skippedSplits.entrySet()) {
+                for (Map.Entry<Autoconfigure.KeySplitStep, Exception> entry : skippedSplits.entrySet()) {
                     exceptionList.add(entry.getValue());
                 }
                 
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
index 1ac8a486..e3fc42f2 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
@@ -2,6 +2,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.Assert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 
 import io.opentdf.platform.policy.Attribute;
@@ -9,8 +10,8 @@
 import io.opentdf.platform.sdk.Autoconfigure.AttributeValueFQN;
 import io.opentdf.platform.sdk.Autoconfigure.Granter.AttributeBooleanExpression;
 import io.opentdf.platform.sdk.Autoconfigure.Granter.BooleanKeyExpression;
+import io.opentdf.platform.sdk.Autoconfigure.KeySplitStep;
 import io.opentdf.platform.sdk.Autoconfigure.Granter;
-import io.opentdf.platform.sdk.Autoconfigure.SplitStep;
 import io.opentdf.platform.policy.Namespace;
 import io.opentdf.platform.policy.KeyAccessServer;
 import io.opentdf.platform.policy.AttributeRuleTypeEnum;
@@ -28,13 +29,13 @@
 
 public class AutoconfigureTest {
 
-    private static final String KAS_AU = "http://kas.au/";
-    private static final String KAS_CA = "http://kas.ca/";
-    private static final String KAS_UK = "http://kas.uk/";
-    private static final String KAS_NZ = "http://kas.nz/";
-    private static final String KAS_US = "http://kas.us/";
-    private static final String KAS_US_HCS = "http://hcs.kas.us/";
-    private static final String KAS_US_SA = "http://si.kas.us/";
+    private static final String KAS_AU = "https://kas.au/";
+    private static final String KAS_CA = "https://kas.ca/";
+    private static final String KAS_UK = "https://kas.uk/";
+    private static final String KAS_NZ = "https://kas.nz/";
+    private static final String KAS_US = "https://kas.us/";
+    private static final String KAS_US_HCS = "https://hcs.kas.us/";
+    private static final String KAS_US_SA = "https://si.kas.us/";
     private static final String AUTHORITY = "https://virtru.com/";
 
     private static Autoconfigure.AttributeNameFQN CLS;
@@ -220,10 +221,10 @@ else if (an.getKey().equals(CLS.getKey())){
     @Test
     public void testAttributeFromURL() throws AutoConfigureException {
         for (TestCase tc : List.of(
-            new TestCase("letter", "http://e/attr/a", "http://e", "a"),
-            new TestCase("number", "http://e/attr/1", "http://e", "1"),
-            new TestCase("emoji", "http://e/attr/%F0%9F%98%81", "http://e", "😁"),
-            new TestCase("dash", "http://a-b.com/attr/b-c", "http://a-b.com", "b-c")
+            new TestCase("letter", "https://e/attr/a", "https://e", "a"),
+            new TestCase("number", "https://e/attr/1", "https://e", "1"),
+            new TestCase("emoji", "https://e/attr/%F0%9F%98%81", "https://e", "😁"),
+            new TestCase("dash", "https://a-b.com/attr/b-c", "https://a-b.com", "b-c")
         )) {
             Autoconfigure.AttributeNameFQN a = new Autoconfigure.AttributeNameFQN(tc.getU());
             assertThat(a.authority()).isEqualTo(tc.getAuth());
@@ -234,13 +235,13 @@ public void testAttributeFromURL() throws AutoConfigureException {
     @Test
     public void testAttributeFromMalformedURL() {
         for (TestCase tc : List.of(
-            new TestCase("no name", "http://e/attr"),
+            new TestCase("no name", "https://e/attr"),
             new TestCase("invalid prefix 1", "hxxp://e/attr/a"),
             new TestCase("invalid prefix 2", "e/attr/a"),
             new TestCase("invalid prefix 3", "file://e/attr/a"),
-            new TestCase("invalid prefix 4", "http:///attr/a"),
+            new TestCase("invalid prefix 4", "https:///attr/a"),
             new TestCase("bad encoding", "https://a/attr/%😁"),
-            new TestCase("with value", "http://e/attr/a/value/b")
+            new TestCase("with value", "https://e/attr/a/value/b")
         )) {
             assertThatThrownBy(() -> new Autoconfigure.AttributeNameFQN(tc.getU()))
                 .isInstanceOf(AutoConfigureException.class);
@@ -250,12 +251,12 @@ public void testAttributeFromMalformedURL() {
     @Test
     public void testAttributeValueFromURL() {
         List<TestCase> testCases = List.of(
-            new TestCase("number", "http://e/attr/a/value/1", "http://e", "a", "1"),
-            new TestCase("space", "http://e/attr/a/value/%20", "http://e", "a", " "),
-            new TestCase("emoji", "http://e/attr/a/value/%F0%9F%98%81", "http://e", "a", "😁"),
-            new TestCase("numberdef", "http://e/attr/1/value/one", "http://e", "1", "one"),
-            new TestCase("valuevalue", "http://e/attr/value/value/one", "http://e", "value", "one"),
-            new TestCase("dash", "http://a-b.com/attr/b-c/value/c-d", "http://a-b.com", "b-c", "c-d")
+            new TestCase("number", "https://e/attr/a/value/1", "https://e", "a", "1"),
+            new TestCase("space", "https://e/attr/a/value/%20", "https://e", "a", " "),
+            new TestCase("emoji", "https://e/attr/a/value/%F0%9F%98%81", "https://e", "a", "😁"),
+            new TestCase("numberdef", "https://e/attr/1/value/one", "https://e", "1", "one"),
+            new TestCase("valuevalue", "https://e/attr/value/value/one", "https://e", "value", "one"),
+            new TestCase("dash", "https://a-b.com/attr/b-c/value/c-d", "https://a-b.com", "b-c", "c-d")
         );
 
         for (TestCase tc : testCases) {
@@ -271,8 +272,8 @@ public void testAttributeValueFromURL() {
     @Test
     public void testAttributeValueFromMalformedURL() {
         List<TestCase> testCases = List.of(
-            new TestCase("no name", "http://e/attr/value/1"),
-            new TestCase("no value", "http://e/attr/who/value"),
+            new TestCase("no name", "https://e/attr/value/1"),
+            new TestCase("no value", "https://e/attr/who/value"),
             new TestCase("invalid prefix 1", "hxxp://e/attr/a/value/1"),
             new TestCase("invalid prefix 2", "e/attr/a/a/value/1"),
             new TestCase("bad encoding", "https://a/attr/emoji/value/%😁")
@@ -326,9 +327,9 @@ public void testReasonerConstructAttributeBoolean() {
                 List.of(clsS, rel2can),
                 List.of(KAS_US),
                 "https://virtru.com/attr/Classification/value/Secret&https://virtru.com/attr/Releasable%20To/value/CAN",
-                "[DEFAULT]&(http://kas.ca/)",
-                "(http://kas.ca/)",
-                List.of(new SplitStep(KAS_CA, ""))
+                "[DEFAULT]&(https://kas.ca/)",
+                "(https://kas.ca/)",
+                List.of(new KeySplitStep(KAS_CA, ""))
             ),
             new ReasonerTestCase(
                 "one defaulted attr",
@@ -337,7 +338,7 @@ public void testReasonerConstructAttributeBoolean() {
                 "https://virtru.com/attr/Classification/value/Secret",
                 "[DEFAULT]",
                 "",
-                List.of(new SplitStep(KAS_US, ""))
+                List.of(new KeySplitStep(KAS_US, ""))
             ),
             new ReasonerTestCase(
                 "empty policy",
@@ -346,7 +347,7 @@ public void testReasonerConstructAttributeBoolean() {
                 "∅",
                 "",
                 "",
-                List.of(new SplitStep(KAS_US, ""))
+                List.of(new KeySplitStep(KAS_US, ""))
             ),
             new ReasonerTestCase(
                 "old school splits",
@@ -355,25 +356,25 @@ public void testReasonerConstructAttributeBoolean() {
                 "∅",
                 "",
                 "",
-                List.of(new SplitStep(KAS_AU, "1"), new SplitStep(KAS_CA, "2"), new SplitStep(KAS_US, "3"))
+                List.of(new KeySplitStep(KAS_AU, "1"), new KeySplitStep(KAS_CA, "2"), new KeySplitStep(KAS_US, "3"))
             ),
             new ReasonerTestCase(
                 "simple with all three ops",
                 List.of(clsS, rel2gbr, n2kInt),
                 List.of(KAS_US),
                 "https://virtru.com/attr/Classification/value/Secret&https://virtru.com/attr/Releasable%20To/value/GBR&https://virtru.com/attr/Need%20to%20Know/value/INT",
-                "[DEFAULT]&(http://kas.uk/)&(http://kas.uk/)",
-                "(http://kas.uk/)",
-                List.of(new SplitStep(KAS_UK, ""))
+                "[DEFAULT]&(https://kas.uk/)&(https://kas.uk/)",
+                "(https://kas.uk/)",
+                List.of(new KeySplitStep(KAS_UK, ""))
             ),
             new ReasonerTestCase(
                 "compartments",
                 List.of(clsS, rel2gbr, rel2usa, n2kHCS, n2kSI),
                 List.of(KAS_US),
                 "https://virtru.com/attr/Classification/value/Secret&https://virtru.com/attr/Releasable%20To/value/{GBR,USA}&https://virtru.com/attr/Need%20to%20Know/value/{HCS,SI}",
-                "[DEFAULT]&(http://kas.uk/⋁http://kas.us/)&(http://hcs.kas.us/⋀http://si.kas.us/)",
-                "(http://kas.uk/⋁http://kas.us/)&(http://hcs.kas.us/)&(http://si.kas.us/)",
-                List.of(new SplitStep(KAS_UK, "1"), new SplitStep(KAS_US, "1"), new SplitStep(KAS_US_HCS, "2"), new SplitStep(KAS_US_SA, "3"))
+                "[DEFAULT]&(https://kas.uk/⋁https://kas.us/)&(https://hcs.kas.us/⋀https://si.kas.us/)",
+                "(https://kas.uk/⋁https://kas.us/)&(https://hcs.kas.us/)&(https://si.kas.us/)",
+                List.of(new KeySplitStep(KAS_UK, "1"), new KeySplitStep(KAS_US, "1"), new KeySplitStep(KAS_US_HCS, "2"), new KeySplitStep(KAS_US_SA, "3"))
             ),
             new ReasonerTestCase(
                 "compartments - case insensitive",
@@ -382,9 +383,9 @@ public void testReasonerConstructAttributeBoolean() {
                 ),
                 List.of(KAS_US),
                 "https://virtru.com/attr/Classification/value/Secret&https://virtru.com/attr/Releasable%20To/value/{GBR,USA}&https://virtru.com/attr/Need%20to%20Know/value/{HCS,SI}",
-                "[DEFAULT]&(http://kas.uk/⋁http://kas.us/)&(http://hcs.kas.us/⋀http://si.kas.us/)",
-                "(http://kas.uk/⋁http://kas.us/)&(http://hcs.kas.us/)&(http://si.kas.us/)",
-                List.of(new SplitStep(KAS_UK, "1"), new SplitStep(KAS_US, "1"), new SplitStep(KAS_US_HCS, "2"), new SplitStep(KAS_US_SA, "3"))
+                "[DEFAULT]&(https://kas.uk/⋁https://kas.us/)&(https://hcs.kas.us/⋀https://si.kas.us/)",
+                "(https://kas.uk/⋁https://kas.us/)&(https://hcs.kas.us/)&(https://si.kas.us/)",
+                List.of(new KeySplitStep(KAS_UK, "1"), new KeySplitStep(KAS_US, "1"), new KeySplitStep(KAS_US_HCS, "2"), new KeySplitStep(KAS_US_SA, "3"))
             )
         );
 
@@ -403,7 +404,7 @@ public void testReasonerConstructAttributeBoolean() {
                 assertThat(reduced).isEqualTo(tc.getReduced());
 
                 var wrapper = new Object(){ int i = 0; };
-                List<SplitStep> plan = reasoner.plan(tc.getDefaults(), () -> {
+                List<KeySplitStep> plan = reasoner.plan(tc.getDefaults(), () -> {
                     return String.valueOf(wrapper.i++ + 1);
                 }
                 
@@ -498,9 +499,9 @@ private static class ReasonerTestCase {
         private final String ats;
         private final String keyed;
         private final String reduced;
-        private final List<SplitStep> plan;
+        private final List<KeySplitStep> plan;
 
-        ReasonerTestCase(String name, List<AttributeValueFQN> policy, List<String> defaults, String ats, String keyed, String reduced, List<SplitStep> plan) {
+        ReasonerTestCase(String name, List<AttributeValueFQN> policy, List<String> defaults, String ats, String keyed, String reduced, List<KeySplitStep> plan) {
             this.name = name;
             this.policy = policy;
             this.defaults = defaults;
@@ -534,7 +535,7 @@ public String getReduced() {
             return reduced;
         }
 
-        public List<SplitStep> getPlan() {
+        public List<KeySplitStep> getPlan() {
             return plan;
         }
     }
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/KASClientTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/KASClientTest.java
index 14b79bfc..8f816548 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/KASClientTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/KASClientTest.java
@@ -61,7 +61,7 @@ public void publicKey(PublicKeyRequest request, StreamObserver<PublicKeyResponse
             try (var kas = new KASClient(channelFactory, dpopKey)) {
                 Config.KASInfo kasInfo = new Config.KASInfo();
                 kasInfo.URL = "localhost:" + rewrapServer.getPort();
-                assertThat(kas.getPublicKey(kasInfo)).isEqualTo("тај је клуц");
+                assertThat(kas.getPublicKey(kasInfo).PublicKey).isEqualTo("тај је клуц");
             }
         } finally {
             if (rewrapServer != null) {
@@ -94,7 +94,7 @@ public void publicKey(PublicKeyRequest request, StreamObserver<PublicKeyResponse
             try (var kas = new KASClient(channelFactory, dpopKey)) {
                 Config.KASInfo kasInfo = new Config.KASInfo();
                 kasInfo.URL = "localhost:" + server.getPort();
-                assertThat(kas.getKid(kasInfo)).isEqualTo("r1");
+                assertThat(kas.getPublicKey(kasInfo).KID).isEqualTo("r1");
             }
         } finally {
             if (server != null) {
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/NanoTDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/NanoTDFTest.java
index 960f1b02..fa209ceb 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/NanoTDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/NanoTDFTest.java
@@ -42,13 +42,10 @@ public void close() throws Exception {
         }
 
         @Override
-        public String getPublicKey(Config.KASInfo kasInfo) {
-            return kasPublicKey;
-        }
-
-        @Override
-        public String getKid(Config.KASInfo kasInfo) {
-            return KID;
+        public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) {
+            Config.KASInfo returnKI = new Config.KASInfo();
+            returnKI.PublicKey = kasPublicKey;
+            return returnKI;
         }
 
         @Override
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
index f2e8b069..a171c4c9 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
@@ -51,15 +51,13 @@ public void setup() {
         public void close() {}
 
         @Override
-        public String getPublicKey(Config.KASInfo kasInfo) {
+        public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) {
             int index = Integer.parseInt(kasInfo.URL);
-
-            return CryptoUtils.getRSAPublicKeyPEM(keypairs.get(index).getPublic());
-        }
-
-        @Override
-        public String getKid(Config.KASInfo kasInfo) {
-            return "r1";
+            var kiCopy = new Config.KASInfo();
+            kiCopy.KID = "r1";
+            kiCopy.PublicKey = CryptoUtils.getRSAPublicKeyPEM(keypairs.get(index).getPublic());
+            kiCopy.URL = kasInfo.URL;
+            return kiCopy;
         }
 
         @Override

From 744f660d222aed933a63030a957bc5079941483e Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Wed, 21 Aug 2024 15:30:09 -0400
Subject: [PATCH 21/23] add tests, get keys from grants

---
 .../opentdf/platform/sdk/Autoconfigure.java   |  36 ++++-
 .../io/opentdf/platform/sdk/KASClient.java    |  93 +------------
 .../io/opentdf/platform/sdk/KASKeyCache.java  |  82 +++++++++++
 .../java/io/opentdf/platform/sdk/SDK.java     |   1 +
 .../java/io/opentdf/platform/sdk/TDF.java     |   3 +-
 .../platform/sdk/AutoconfigureTest.java       | 122 ++++++++++++++++-
 .../opentdf/platform/sdk/KASKeyCacheTest.java | 129 ++++++++++++++++++
 .../io/opentdf/platform/sdk/NanoTDFTest.java  |   5 +
 .../java/io/opentdf/platform/sdk/TDFTest.java |   5 +
 9 files changed, 385 insertions(+), 91 deletions(-)
 create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java
 create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index 476c5439..9c8ac19d 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -31,8 +31,10 @@
 import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse.AttributeAndValue;
 import io.opentdf.platform.policy.Attribute;
 import io.opentdf.platform.policy.Value;
+import io.opentdf.platform.policy.KasPublicKey;
 import io.opentdf.platform.policy.AttributeValueSelector;
 import io.opentdf.platform.policy.AttributeRuleTypeEnum;
+import io.opentdf.platform.policy.KasPublicKeyAlgEnum;
 
 // Attribute rule types: operators!
 class RuleType {
@@ -668,7 +670,7 @@ public static String ruleToOperator(AttributeRuleTypeEnum e) {
     }
 
     // Gets a list of directory of KAS grants for a list of attribute FQNs
-    public static Granter newGranterFromService(AttributesServiceFutureStub as, AttributeValueFQN... fqns) throws AutoConfigureException, InterruptedException, ExecutionException {
+    public static Granter newGranterFromService(AttributesServiceFutureStub as, KASKeyCache keyCache, AttributeValueFQN... fqns) throws AutoConfigureException, InterruptedException, ExecutionException {
         String[] fqnsStr = new String[fqns.length];
         for (int i = 0; i < fqns.length; i++) {
             fqnsStr[i] = fqns[i].toString();
@@ -698,11 +700,13 @@ public static Granter newGranterFromService(AttributesServiceFutureStub as, Attr
             Attribute def = pair.getAttribute();
             if (def != null) {
                 grants.addAllGrants(fqn, def.getGrantsList(), def);
+                storeKeysToCache(def.getGrantsList(), keyCache);
             }
 
             Value v = pair.getValue();
             if (v != null) {
                 grants.addAllGrants(fqn, v.getGrantsList(), def);
+                storeKeysToCache(v.getGrantsList(), keyCache);
             }
         }
 
@@ -738,4 +742,34 @@ public static Granter newGranterFromAttributes(Value... attrs) throws AutoConfig
         return grants;
     }
 
+    static void storeKeysToCache(List<KeyAccessServer> kases, KASKeyCache keyCache) {
+        for (KeyAccessServer kas : kases) {
+            List<KasPublicKey> keys = kas.getPublicKey().getCached().getKeysList();
+            if (keys.isEmpty()) {
+                System.out.println("No cached key in policy service for KAS: " + kas.getUri());
+                continue;
+            }
+            for (KasPublicKey ki : keys) {
+                Config.KASInfo kasInfo = new Config.KASInfo();
+                kasInfo.URL = kas.getUri();
+                kasInfo.KID = ki.getKid();
+                kasInfo.Algorithm = algProto2String(ki.getAlg());
+                kasInfo.PublicKey = ki.getPem();
+                keyCache.store(kasInfo);
+            }
+        }
+    }
+
+    private static String algProto2String(KasPublicKeyAlgEnum e) {
+        switch (e) {
+            case KAS_PUBLIC_KEY_ALG_ENUM_EC_SECP256R1:
+                return "ec:secp256r1";
+            case KAS_PUBLIC_KEY_ALG_ENUM_RSA_2048:
+                return "rsa:2048";
+            case KAS_PUBLIC_KEY_ALG_ENUM_UNSPECIFIED:
+            default:
+                return "";
+        }
+    }
+
 }
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java
index 9d2f0c84..2473b506 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASClient.java
@@ -1,7 +1,6 @@
 package io.opentdf.platform.sdk;
 
 import com.google.gson.Gson;
-import com.google.gson.annotations.SerializedName;
 import com.nimbusds.jose.JOSEException;
 import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jose.JWSHeader;
@@ -14,7 +13,6 @@
 import io.opentdf.platform.kas.PublicKeyRequest;
 import io.opentdf.platform.kas.PublicKeyResponse;
 import io.opentdf.platform.kas.RewrapRequest;
-import io.opentdf.platform.sdk.Autoconfigure.KeySplitStep;
 import io.opentdf.platform.sdk.nanotdf.ECKeyPair;
 import io.opentdf.platform.sdk.nanotdf.NanoTDFType;
 
@@ -24,12 +22,9 @@
 import java.net.URL;
 import java.time.Duration;
 import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
-import java.util.Map;
 import java.util.function.Function;
 
 import static java.lang.String.format;
@@ -64,7 +59,6 @@ public KASClient(Function <String, ManagedChannel> channelFactory, RSAKey dpopKe
 
     @Override
     public String getECPublicKey(Config.KASInfo kasInfo, NanoTDFType.ECCurve curve) {
-        //String.format("ec:%s", curve.toString())
         return getStub(kasInfo.URL)
                 .publicKey(PublicKeyRequest.newBuilder().setAlgorithm(String.format("ec:%s", curve.toString())).build())
                 .getPublicKey();
@@ -82,17 +76,16 @@ public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) {
         kiCopy.KID = resp.getKid();
         kiCopy.PublicKey = resp.getPublicKey();
         kiCopy.URL = kasInfo.URL;
+        kiCopy.Algorithm = kasInfo.Algorithm;
 
         this.kasKeyCache.store(kiCopy);
         return kiCopy;
     }
 
-    // @Override
-    // public String getKid(Config.KASInfo kasInfo) {
-    //     return getStub(kasInfo.URL)
-    //             .publicKey(PublicKeyRequest.getDefaultInstance())
-    //             .getKid();
-    // }
+    @Override
+    public KASKeyCache getKeyCache(){
+        return this.kasKeyCache;
+    }
 
     private String normalizeAddress(String urlString) {
         URL url;
@@ -130,82 +123,6 @@ public synchronized void close() {
         }
     }
 
-    class TimeStampedKASInfo {
-        Config.KASInfo kasInfo;
-        LocalDateTime timestamp;
-
-        public TimeStampedKASInfo(Config.KASInfo kasInfo, LocalDateTime timestamp) {
-            this.kasInfo = kasInfo;
-            this.timestamp = timestamp;
-        }
-    }
-
-    class KASKeyRequest {
-        private String url;
-        private String algorithm;
-    
-        public KASKeyRequest(String url, String algorithm) {
-            this.url = url;
-            this.algorithm = algorithm;
-        }
-    
-        // Override equals and hashCode to ensure proper functioning of the HashMap
-        @Override
-        public boolean equals(Object o) {
-            if (this == o) return true;
-            if (o == null || !(o instanceof KASKeyRequest)) return false;
-            KASKeyRequest that = (KASKeyRequest) o;
-           if (algorithm == null){
-                return url.equals(that.url);
-            }
-            return url.equals(that.url) && algorithm.equals(that.algorithm);
-        }
-    
-        @Override
-        public int hashCode() {
-            int result = 31 * url.hashCode();
-            if (algorithm != null) {
-                result = result + algorithm.hashCode();
-            }
-            return result;
-        }
-    }
-
-    class KASKeyCache {
-        private Map<KASKeyRequest, TimeStampedKASInfo> cache;
-
-        public KASKeyCache() {
-            this.cache = new HashMap<>();
-        }
-
-        public void clear() {
-            this.cache = new HashMap<>();
-        }
-
-        public Config.KASInfo get(String url, String algorithm) {
-            KASKeyRequest cacheKey = new KASKeyRequest(url, algorithm);
-            LocalDateTime now = LocalDateTime.now();
-            TimeStampedKASInfo cachedValue = cache.get(cacheKey);
-
-            if (cachedValue == null) {
-                return null;
-            }
-
-            LocalDateTime anHourAgo = now.minus(1, ChronoUnit.HOURS);
-            if (anHourAgo.isAfter(cachedValue.timestamp)) {
-                cache.remove(cacheKey);
-                return null;
-            }
-
-            return cachedValue.kasInfo;
-        }
-
-        public void store(Config.KASInfo kasInfo) {
-            KASKeyRequest cacheKey = new KASKeyRequest(kasInfo.URL, kasInfo.Algorithm);
-            cache.put(cacheKey, new TimeStampedKASInfo(kasInfo, LocalDateTime.now()));
-        }
-    }
-
     static class RewrapRequestBody {
         String policy;
         String clientPublicKey;
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java
new file mode 100644
index 00000000..b6e4eb78
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java
@@ -0,0 +1,82 @@
+package io.opentdf.platform.sdk;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+
+public class KASKeyCache {
+    Map<KASKeyRequest, TimeStampedKASInfo> cache;
+
+    public KASKeyCache() {
+        this.cache = new HashMap<>();
+    }
+
+    public void clear() {
+        this.cache = new HashMap<>();
+    }
+
+    public Config.KASInfo get(String url, String algorithm) {
+        KASKeyRequest cacheKey = new KASKeyRequest(url, algorithm);
+        LocalDateTime now = LocalDateTime.now();
+        TimeStampedKASInfo cachedValue = cache.get(cacheKey);
+
+        if (cachedValue == null) {
+            return null;
+        }
+
+        LocalDateTime anHourAgo = now.minus(1, ChronoUnit.HOURS);
+        if (anHourAgo.isAfter(cachedValue.timestamp)) {
+            cache.remove(cacheKey);
+            return null;
+        }
+
+        return cachedValue.kasInfo;
+    }
+
+    public void store(Config.KASInfo kasInfo) {
+        KASKeyRequest cacheKey = new KASKeyRequest(kasInfo.URL, kasInfo.Algorithm);
+        cache.put(cacheKey, new TimeStampedKASInfo(kasInfo, LocalDateTime.now()));
+    }
+}
+
+class TimeStampedKASInfo {
+    Config.KASInfo kasInfo;
+    LocalDateTime timestamp;
+
+    public TimeStampedKASInfo(Config.KASInfo kasInfo, LocalDateTime timestamp) {
+        this.kasInfo = kasInfo;
+        this.timestamp = timestamp;
+    }
+}
+
+class KASKeyRequest {
+    private String url;
+    private String algorithm;
+
+    public KASKeyRequest(String url, String algorithm) {
+        this.url = url;
+        this.algorithm = algorithm;
+    }
+
+    // Override equals and hashCode to ensure proper functioning of the HashMap
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || !(o instanceof KASKeyRequest)) return false;
+        KASKeyRequest that = (KASKeyRequest) o;
+       if (algorithm == null){
+            return url.equals(that.url);
+        }
+        return url.equals(that.url) && algorithm.equals(that.algorithm);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = 31 * url.hashCode();
+        if (algorithm != null) {
+            result = result + algorithm.hashCode();
+        }
+        return result;
+    }
+}
\ No newline at end of file
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 a1b72689..87d6557e 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
@@ -42,6 +42,7 @@ public interface KAS extends AutoCloseable {
         String getECPublicKey(Config.KASInfo kasInfo, NanoTDFType.ECCurve curve);
         byte[] unwrap(Manifest.KeyAccess keyAccess, String policy);
         byte[] unwrapNanoTDF(NanoTDFType.ECCurve curve, String header, String kasURL);
+        KASKeyCache getKeyCache();
     }
 
     // TODO: add KAS
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index 3c586048..0b2631a4 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -215,6 +215,7 @@ private void prepareManifest(Config.TDFConfig tdfConfig, SDK.KAS kas) {
                     logger.info("no public key provided for KAS at {}, retrieving", splitInfo.kas);
                     var getKI = new Config.KASInfo();
                     getKI.URL = splitInfo.kas;
+                    getKI.Algorithm = "rsa:2048";
                     getKI = kas.getPublicKey(getKI);
                     latestKASInfo.put(splitInfo.kas, getKI);
                     ki = getKI;
@@ -383,7 +384,7 @@ public TDFObject createTDF(InputStream payload,
             if (tdfConfig.attributeValues != null && !tdfConfig.attributeValues.isEmpty()) {
                 granter = Autoconfigure.newGranterFromAttributes(tdfConfig.attributeValues.toArray(new Value[0]));
             } else if (tdfConfig.attributes != null && !tdfConfig.attributes.isEmpty()) {
-                granter = Autoconfigure.newGranterFromService(attrService, tdfConfig.attributes.toArray(new AttributeValueFQN[0]));
+                granter = Autoconfigure.newGranterFromService(attrService, kas.getKeyCache(), tdfConfig.attributes.toArray(new AttributeValueFQN[0]));
             }
         
             if (granter == null) {
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
index e3fc42f2..7bf3985a 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
@@ -2,8 +2,12 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.Assert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 
 import io.opentdf.platform.policy.Attribute;
 import io.opentdf.platform.policy.Value;
@@ -13,11 +17,16 @@
 import io.opentdf.platform.sdk.Autoconfigure.KeySplitStep;
 import io.opentdf.platform.sdk.Autoconfigure.Granter;
 import io.opentdf.platform.policy.Namespace;
+import io.opentdf.platform.policy.PublicKey;
 import io.opentdf.platform.policy.KeyAccessServer;
 import io.opentdf.platform.policy.AttributeRuleTypeEnum;
+import io.opentdf.platform.policy.KasPublicKey;
+import io.opentdf.platform.policy.KasPublicKeyAlgEnum;
+import io.opentdf.platform.policy.KasPublicKeySet;
 
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
 
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -539,4 +548,115 @@ public List<KeySplitStep> getPlan() {
             return plan;
         }
     }
+
+    @Test
+    void testStoreKeysToCache_NoKeys() {
+        KASKeyCache keyCache = Mockito.mock(KASKeyCache.class);
+        KeyAccessServer kas1 = KeyAccessServer.newBuilder().setPublicKey(
+            PublicKey.newBuilder().setCached(
+                KasPublicKeySet.newBuilder())
+                ).build();
+
+
+        List<KeyAccessServer> kases = List.of(kas1);
+
+        Autoconfigure.storeKeysToCache(kases, keyCache);
+
+        verify(keyCache, never()).store(any(Config.KASInfo.class));
+    }
+
+    @Test
+    void testStoreKeysToCache_WithKeys() {
+        // Create a real KASKeyCache instance instead of mocking it
+        KASKeyCache keyCache = new KASKeyCache();
+
+        // Create the KasPublicKey object
+        KasPublicKey kasPublicKey1 = KasPublicKey.newBuilder()
+            .setAlg(KasPublicKeyAlgEnum.KAS_PUBLIC_KEY_ALG_ENUM_EC_SECP256R1)
+            .setKid("test-kid")
+            .setPem("public-key-pem")
+            .build();
+
+        // Add the KasPublicKey to a list
+        List<KasPublicKey> kasPublicKeys = new ArrayList<>();
+        kasPublicKeys.add(kasPublicKey1);
+
+        // Create the KeyAccessServer object
+        KeyAccessServer kas1 = KeyAccessServer.newBuilder()
+            .setPublicKey(PublicKey.newBuilder()
+                .setCached(KasPublicKeySet.newBuilder()
+                    .addAllKeys(kasPublicKeys)
+                    .build())
+            )
+            .setUri("https://example.com/kas")
+            .build();
+
+        // Add the KeyAccessServer to a list
+        List<KeyAccessServer> kases = List.of(kas1);
+
+        // Call the method under test
+        Autoconfigure.storeKeysToCache(kases, keyCache);
+
+        // Verify that the key was stored in the cache
+        Config.KASInfo storedKASInfo = keyCache.get("https://example.com/kas", "ec:secp256r1");
+        assertNotNull(storedKASInfo);
+        assertEquals("https://example.com/kas", storedKASInfo.URL);
+        assertEquals("test-kid", storedKASInfo.KID);
+        assertEquals("ec:secp256r1", storedKASInfo.Algorithm);
+        assertEquals("public-key-pem", storedKASInfo.PublicKey);
+    }
+
+    @Test
+    void testStoreKeysToCache_MultipleKasEntries() {
+        // Create a real KASKeyCache instance instead of mocking it
+        KASKeyCache keyCache = new KASKeyCache();
+
+        // Create the KasPublicKey object
+        KasPublicKey kasPublicKey1 = KasPublicKey.newBuilder()
+            .setAlg(KasPublicKeyAlgEnum.KAS_PUBLIC_KEY_ALG_ENUM_EC_SECP256R1)
+            .setKid("test-kid")
+            .setPem("public-key-pem")
+            .build();
+        KasPublicKey kasPublicKey2 = KasPublicKey.newBuilder()
+            .setAlg(KasPublicKeyAlgEnum.KAS_PUBLIC_KEY_ALG_ENUM_RSA_2048)
+            .setKid("test-kid-2")
+            .setPem("public-key-pem-2")
+            .build();
+
+        // Add the KasPublicKey to a list
+        List<KasPublicKey> kasPublicKeys = new ArrayList<>();
+        kasPublicKeys.add(kasPublicKey1);
+        kasPublicKeys.add(kasPublicKey2);
+
+        // Create the KeyAccessServer object
+        KeyAccessServer kas1 = KeyAccessServer.newBuilder()
+            .setPublicKey(PublicKey.newBuilder()
+                .setCached(KasPublicKeySet.newBuilder()
+                    .addAllKeys(kasPublicKeys)
+                    .build())
+            )
+            .setUri("https://example.com/kas")
+            .build();
+
+        // Add the KeyAccessServer to a list
+        List<KeyAccessServer> kases = List.of(kas1);
+
+        // Call the method under test
+        Autoconfigure.storeKeysToCache(kases, keyCache);
+
+        // Verify that the key was stored in the cache
+        Config.KASInfo storedKASInfo = keyCache.get("https://example.com/kas", "ec:secp256r1");
+        assertNotNull(storedKASInfo);
+        assertEquals("https://example.com/kas", storedKASInfo.URL);
+        assertEquals("test-kid", storedKASInfo.KID);
+        assertEquals("ec:secp256r1", storedKASInfo.Algorithm);
+        assertEquals("public-key-pem", storedKASInfo.PublicKey);
+
+        Config.KASInfo storedKASInfo2 = keyCache.get("https://example.com/kas", "rsa:2048");
+        assertNotNull(storedKASInfo2);
+        assertEquals("https://example.com/kas", storedKASInfo2.URL);
+        assertEquals("test-kid-2", storedKASInfo2.KID);
+        assertEquals("rsa:2048", storedKASInfo2.Algorithm);
+        assertEquals("public-key-pem-2", storedKASInfo2.PublicKey);
+    }
 }
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java
new file mode 100644
index 00000000..ec371726
--- /dev/null
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java
@@ -0,0 +1,129 @@
+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 static org.junit.jupiter.api.Assertions.*;
+
+class KASKeyCacheTest {
+
+    private KASKeyCache kasKeyCache;
+    private Config.KASInfo kasInfo1;
+    private Config.KASInfo kasInfo2;
+
+    @BeforeEach
+    void setUp() {
+        kasKeyCache = new KASKeyCache();
+        kasInfo1 = new Config.KASInfo();
+        kasInfo1.Algorithm = "rsa:2048";
+        kasInfo1.URL = "https://example.com/kas1";
+        kasInfo1.KID = "kid1";
+        kasInfo1.PublicKey = "publicKey1";
+        kasInfo2 = new Config.KASInfo();
+        kasInfo2.URL = "https://example.com/kas2";
+        kasInfo2.Algorithm = "ec:secp256r1";
+        kasInfo2.KID = "kid2";
+        kasInfo2.PublicKey = "publicKey2";
+    }
+
+    @Test
+    void testStoreAndGet_WithinTimeLimit() {
+        // Store an item in the cache
+        kasKeyCache.store(kasInfo1);
+
+        // Retrieve the item within the time limit
+        Config.KASInfo result = kasKeyCache.get("https://example.com/kas1", "rsa:2048");
+
+        // Ensure the item was correctly retrieved
+        assertNotNull(result);
+        assertEquals("https://example.com/kas1", result.URL);
+        assertEquals("rsa:2048", result.Algorithm);
+        assertEquals("kid1", result.KID);
+        assertEquals("publicKey1", result.PublicKey);
+    }
+
+    @Test
+    void testStoreAndGet_AfterTimeLimit() {
+        // Store an item in the cache
+        kasKeyCache.store(kasInfo1);
+
+        // Simulate time passing by modifying the timestamp directly
+        KASKeyRequest cacheKey = new KASKeyRequest("https://example.com/kas1", "rsa:2048");
+        TimeStampedKASInfo timeStampedKASInfo = new TimeStampedKASInfo(kasInfo1, LocalDateTime.now().minus(2, ChronoUnit.HOURS));
+        kasKeyCache.cache.put(cacheKey, timeStampedKASInfo);
+
+        // Attempt to retrieve the item after the time limit
+        Config.KASInfo result = kasKeyCache.get("https://example.com/kas1", "rsa:2048");
+
+        // Ensure the item was not retrieved (it should have expired)
+        assertNull(result);
+    }
+
+    @Test
+    void testStoreAndGet_WithNullAlgorithm() {
+        // Store an item in the cache with a null algorithm
+        kasInfo1 = new Config.KASInfo();
+        kasInfo1.URL = "https://example.com/kas1";
+        kasInfo1.KID = "kid1";
+        kasInfo1.PublicKey = "publicKey1";
+        kasKeyCache.store(kasInfo1);
+
+        // Retrieve the item with a null algorithm
+        Config.KASInfo result = kasKeyCache.get("https://example.com/kas1", null);
+
+        // Ensure the item was correctly retrieved
+        assertNotNull(result);
+        assertEquals("https://example.com/kas1", result.URL);
+        assertNull(result.Algorithm);
+        assertEquals("kid1", result.KID);
+        assertEquals("publicKey1", result.PublicKey);
+    }
+
+    @Test
+    void testClearCache() {
+        // Store an item in the cache
+        kasKeyCache.store(kasInfo1);
+
+        // Clear the cache
+        kasKeyCache.clear();
+
+        // Attempt to retrieve the item after clearing the cache
+        Config.KASInfo result = kasKeyCache.get("https://example.com/kas1", "rsa:2048");
+
+        // Ensure the item was not retrieved (the cache should be empty)
+        assertNull(result);
+    }
+
+    @Test
+    void testStoreMultipleItemsAndGet() {
+        // Store multiple items in the cache
+        kasKeyCache.store(kasInfo1);
+        kasKeyCache.store(kasInfo2);
+
+        // Retrieve each item and ensure they were correctly stored and retrieved
+        Config.KASInfo result1 = kasKeyCache.get("https://example.com/kas1", "rsa:2048");
+        Config.KASInfo result2 = kasKeyCache.get("https://example.com/kas2", "ec:secp256r1");
+
+        assertNotNull(result1);
+        assertEquals("https://example.com/kas1", result1.URL);
+        assertEquals("rsa:2048", result1.Algorithm);
+
+        assertNotNull(result2);
+        assertEquals("https://example.com/kas2", result2.URL);
+        assertEquals("ec:secp256r1", result2.Algorithm);
+    }
+
+    @Test
+    void testEqualsAndHashCode() {
+        // Create two identical KASKeyRequest objects
+        KASKeyRequest keyRequest1 = new KASKeyRequest("https://example.com/kas1", "rsa:2048");
+        KASKeyRequest keyRequest2 = new KASKeyRequest("https://example.com/kas1", "rsa:2048");
+
+        // Ensure that equals and hashCode work as expected
+        assertEquals(keyRequest1, keyRequest2);
+        assertEquals(keyRequest1.hashCode(), keyRequest2.hashCode());
+    }
+}
\ No newline at end of file
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/NanoTDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/NanoTDFTest.java
index fa209ceb..33f0bbe4 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/NanoTDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/NanoTDFTest.java
@@ -89,6 +89,11 @@ public byte[] unwrapNanoTDF(NanoTDFType.ECCurve curve, String header, String kas
             byte[] key = ECKeyPair.calculateHKDF(hashOfSalt, symmetricKey);
             return key;
         }
+
+        @Override
+        public KASKeyCache getKeyCache(){
+            return new KASKeyCache();
+        }
     };
 
     private static ArrayList<KeyPair> keypairs = new ArrayList<>();
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
index a171c4c9..af4d32cb 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
@@ -81,6 +81,11 @@ public String getECPublicKey(Config.KASInfo kasInfo, NanoTDFType.ECCurve curve)
         public byte[] unwrapNanoTDF(NanoTDFType.ECCurve curve, String header, String kasURL) {
             return null;
         }
+
+        @Override
+        public KASKeyCache getKeyCache(){
+            return new KASKeyCache();
+        }
     };
 
     AttributesServiceGrpc.AttributesServiceFutureStub attributeGrpcStub;

From 7644218958efc79294318de0d2b33eed2b426ec2 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Thu, 22 Aug 2024 10:48:11 -0400
Subject: [PATCH 22/23] more tests

---
 .github/workflows/checks.yaml                 |   2 +
 .../opentdf/platform/sdk/Autoconfigure.java   |   7 +-
 .../platform/sdk/AutoconfigureTest.java       | 271 +++++++++++++++++-
 3 files changed, 270 insertions(+), 10 deletions(-)

diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index 947833fe..59069682 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -2,6 +2,8 @@ name: "Checks"
 
 on:
   pull_request:
+    branches:
+      - main
   push:
     branches:
       - main
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
index 58e14472..29b7c4d5 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Autoconfigure.java
@@ -59,6 +59,11 @@ public KeySplitStep(String kas, String splitId) {
             this.splitID = splitId;
         }
 
+        @Override
+        public String toString() {
+            return "KeySplitStep{kas=" + this.kas + ", splitID=" + this.splitID + "}";
+        }
+
         @Override
         public boolean equals(Object obj) {
             if (this == obj) {
@@ -746,7 +751,7 @@ static void storeKeysToCache(List<KeyAccessServer> kases, KASKeyCache keyCache)
         for (KeyAccessServer kas : kases) {
             List<KasPublicKey> keys = kas.getPublicKey().getCached().getKeysList();
             if (keys.isEmpty()) {
-                System.out.println("No cached key in policy service for KAS: " + kas.getUri());
+                logger.debug("No cached key in policy service for KAS: " + kas.getUri());
                 continue;
             }
             for (KasPublicKey ki : keys) {
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
index 7bf3985a..7f761ac5 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AutoconfigureTest.java
@@ -6,11 +6,16 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 
 import io.opentdf.platform.policy.Attribute;
 import io.opentdf.platform.policy.Value;
+import io.opentdf.platform.policy.attributes.AttributesServiceGrpc;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
 import io.opentdf.platform.sdk.Autoconfigure.AttributeValueFQN;
 import io.opentdf.platform.sdk.Autoconfigure.Granter.AttributeBooleanExpression;
 import io.opentdf.platform.sdk.Autoconfigure.Granter.BooleanKeyExpression;
@@ -28,10 +33,13 @@
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
+import com.google.common.util.concurrent.SettableFuture;
+
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.ExecutionException;
 import java.util.stream.Collectors;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -46,10 +54,15 @@ public class AutoconfigureTest {
     private static final String KAS_US_HCS = "https://hcs.kas.us/";
     private static final String KAS_US_SA = "https://si.kas.us/";
     private static final String AUTHORITY = "https://virtru.com/";
+    public static final String OTHER_AUTH = "https://other.com/";
+    public static final String SPECIFIED_KAS = "https://attr.kas.com/";
+    public static final String EVEN_MORE_SPECIFIC_KAS = "https://value.kas.com/";
 
     private static Autoconfigure.AttributeNameFQN CLS;
     private static Autoconfigure.AttributeNameFQN N2K;
     private static Autoconfigure.AttributeNameFQN REL;
+    private static Autoconfigure.AttributeNameFQN UNSPECKED;
+    private static Autoconfigure.AttributeNameFQN SPECKED;
 
     private static Autoconfigure.AttributeValueFQN clsA;
     private static Autoconfigure.AttributeValueFQN clsS;
@@ -61,6 +74,10 @@ public class AutoconfigureTest {
     private static Autoconfigure.AttributeValueFQN rel2gbr;
     private static Autoconfigure.AttributeValueFQN rel2nzl;
     private static Autoconfigure.AttributeValueFQN rel2usa;
+    private static Autoconfigure.AttributeValueFQN uns2uns;
+    private static Autoconfigure.AttributeValueFQN uns2spk;
+    private static Autoconfigure.AttributeValueFQN spk2uns;
+    private static Autoconfigure.AttributeValueFQN spk2spk;
 
     @BeforeAll
     public static void setup() throws AutoConfigureException {
@@ -68,6 +85,9 @@ public static void setup() throws AutoConfigureException {
         CLS = new Autoconfigure.AttributeNameFQN("https://virtru.com/attr/Classification");
         N2K = new Autoconfigure.AttributeNameFQN("https://virtru.com/attr/Need%20to%20Know");
         REL = new Autoconfigure.AttributeNameFQN("https://virtru.com/attr/Releasable%20To");
+        UNSPECKED = new Autoconfigure.AttributeNameFQN("https://other.com/attr/unspecified");
+        SPECKED = new Autoconfigure.AttributeNameFQN("https://other.com/attr/specified");
+
 
         clsA = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Classification/value/Allowed");
         clsS = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Classification/value/Secret");
@@ -81,6 +101,11 @@ public static void setup() throws AutoConfigureException {
         rel2gbr = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Releasable%20To/value/GBR");
         rel2nzl = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Releasable%20To/value/NZL");
         rel2usa = new Autoconfigure.AttributeValueFQN("https://virtru.com/attr/Releasable%20To/value/USA");
+
+        uns2uns = new Autoconfigure.AttributeValueFQN("https://other.com/attr/unspecified/value/unspecked");
+        uns2spk = new Autoconfigure.AttributeValueFQN("https://other.com/attr/unspecified/value/specked");
+        spk2uns = new Autoconfigure.AttributeValueFQN("https://other.com/attr/specified/value/unspecked");
+        spk2spk = new Autoconfigure.AttributeValueFQN("https://other.com/attr/specified/value/specked");
     }
 
     private static String spongeCase(String s) {
@@ -142,22 +167,31 @@ private Autoconfigure.AttributeValueFQN messUpV(Autoconfigure.AttributeValueFQN
     }
 
     private Attribute mockAttributeFor(Autoconfigure.AttributeNameFQN fqn) {
+        Namespace ns1 = Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build();
+        Namespace ns2 = Namespace.newBuilder().setId("o").setName("other.com").setFqn("https://other.com").build();
         String key = fqn.getKey();
         if (key.equals(CLS.getKey())){
-                return Attribute.newBuilder().setId("CLS").setNamespace(
-                    Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
+                return Attribute.newBuilder().setId("CLS").setNamespace(ns1)
                     .setName("Classification").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_HIERARCHY).setFqn(fqn.toString()).build();
         }
         else if (key.equals(N2K.getKey())) {
-            return Attribute.newBuilder().setId("N2K").setNamespace(
-                    Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
+            return Attribute.newBuilder().setId("N2K").setNamespace(ns1)
                     .setName("Need to Know").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ALL_OF).setFqn(fqn.toString()).build();
         }
         else if (key.equals(REL.getKey())) {
-            return Attribute.newBuilder().setId("REL").setNamespace(
-                    Namespace.newBuilder().setId("v").setName("virtru.com").setFqn("https://virtru.com").build())
+            return Attribute.newBuilder().setId("REL").setNamespace(ns1)
                     .setName("Releasable To").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF).setFqn(fqn.toString()).build();
         }
+        else if (key.equals(SPECKED.getKey())) {
+            return Attribute.newBuilder().setId("SPK").setNamespace(ns2)
+                    .setName("specified").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF).setFqn(fqn.toString())
+                    .addGrants(KeyAccessServer.newBuilder().setUri(SPECIFIED_KAS).build())
+                    .build();
+        }
+        else if (key.equals(UNSPECKED.getKey())) {
+            return Attribute.newBuilder().setId("UNS").setNamespace(ns2)
+                    .setName("unspecified").setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF).setFqn(fqn.toString()).build();
+        }
         else {
             return null;
         }
@@ -165,9 +199,7 @@ else if (key.equals(REL.getKey())) {
 
     private Value mockValueFor(Autoconfigure.AttributeValueFQN fqn) throws AutoConfigureException {
         Autoconfigure.AttributeNameFQN an = fqn.prefix();
-        System.out.println(an);
         Attribute a = mockAttributeFor(an);
-        System.out.println(a);
         String v = fqn.value();
         Value p = Value.newBuilder()
         .setId(a.getId() + ":" + v)
@@ -224,6 +256,18 @@ else if (an.getKey().equals(REL.getKey())) {
         else if (an.getKey().equals(CLS.getKey())){
             // defaults only
         }
+        else if (an.getKey().equals(SPECKED.getKey())){
+            if (fqn.value().toLowerCase().equals("specked")){
+                p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(EVEN_MORE_SPECIFIC_KAS).build())
+                        .build();
+            }
+        }
+        else if (an.getKey().equals(UNSPECKED.getKey())){
+            if (fqn.value().toLowerCase().equals("specked")){
+                p = p.toBuilder().addGrants(KeyAccessServer.newBuilder().setUri(EVEN_MORE_SPECIFIC_KAS).build())
+                        .build();
+            }
+        }
         return p;
     }
 
@@ -306,7 +350,6 @@ public void testConfigurationServicePutGet() {
         );
 
         for (ConfigurationTestCase tc : testCases) {
-            System.out.println(tc.name);
             assertDoesNotThrow(() -> {
                 List<Value> v = valuesToPolicy(tc.getPolicy().toArray(new AttributeValueFQN[0]));
                 Granter grants = Autoconfigure.newGranterFromAttributes(v.toArray(new Value[0]));
@@ -425,6 +468,120 @@ public void testReasonerConstructAttributeBoolean() {
         }
     }
 
+    GetAttributeValuesByFqnsResponse getResponse(GetAttributeValuesByFqnsRequest req) {
+        GetAttributeValuesByFqnsResponse.Builder builder = GetAttributeValuesByFqnsResponse.newBuilder();
+
+        for (String v : req.getFqnsList()) {
+            AttributeValueFQN vfqn;
+            try {
+                vfqn = new AttributeValueFQN(v);
+            } catch (Exception e) {
+                return null;  // Or throw the exception as needed
+            }
+
+            Value val = mockValueFor(vfqn);
+            
+            builder.putFqnAttributeValues(v, GetAttributeValuesByFqnsResponse.AttributeAndValue.newBuilder()
+            .setAttribute(val.getAttribute())
+            .setValue(val)
+            .build());
+        }
+
+        return builder.build();
+    }
+
+    @Test
+    public void testReasonerSpecificity() {
+        List<ReasonerTestCase> testCases = List.of(
+            new ReasonerTestCase(
+                "uns.uns => default",
+                List.of(uns2uns),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(KAS_US, ""))
+            ),
+            new ReasonerTestCase(
+                "uns.spk => spk",
+                List.of(uns2spk),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(EVEN_MORE_SPECIFIC_KAS, ""))
+            ),
+            new ReasonerTestCase(
+                "spk.uns => spk",
+                List.of(spk2uns),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(SPECIFIED_KAS, ""))
+            ),
+            new ReasonerTestCase(
+                "spk.spk => value.spk",
+                List.of(spk2spk),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(EVEN_MORE_SPECIFIC_KAS, ""))
+            ),
+            new ReasonerTestCase(
+                "spk.spk & spk.uns => value.spk || attr.spk",
+                List.of(spk2spk, spk2uns),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(EVEN_MORE_SPECIFIC_KAS, "1"), new KeySplitStep(SPECIFIED_KAS, "1"))
+            ),
+            new ReasonerTestCase(
+                "spk.uns & spk.spk => value.spk || attr.spk",
+                List.of(spk2uns, spk2spk),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(SPECIFIED_KAS, "1"), new KeySplitStep(EVEN_MORE_SPECIFIC_KAS, "1"))
+            ),
+            new ReasonerTestCase(
+                "uns.spk & spk.spk => value.spk",
+                List.of(spk2spk, uns2spk),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(EVEN_MORE_SPECIFIC_KAS, ""))
+            ),
+            new ReasonerTestCase(
+                "uns.spk & uns.uns => spk",
+                List.of(uns2spk, uns2uns),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(EVEN_MORE_SPECIFIC_KAS, ""))
+            ),
+            new ReasonerTestCase(
+                "uns.uns & uns.spk => spk",
+                List.of(uns2uns, uns2spk),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(EVEN_MORE_SPECIFIC_KAS, ""))
+            ),
+            new ReasonerTestCase(
+                "uns.uns & uns.spk => spk",
+                List.of(uns2uns, spk2spk),
+                List.of(KAS_US),
+                List.of(new KeySplitStep(EVEN_MORE_SPECIFIC_KAS, ""))
+            )
+        );
+
+        for (ReasonerTestCase tc : testCases) {
+            assertDoesNotThrow(() -> {
+                AttributesServiceGrpc.AttributesServiceFutureStub attributeGrpcStub = mock(AttributesServiceGrpc.AttributesServiceFutureStub.class);
+                lenient().when(attributeGrpcStub.getAttributeValuesByFqns(any(GetAttributeValuesByFqnsRequest.class))).thenAnswer(
+                    invocation -> {
+                        GetAttributeValuesByFqnsResponse resp = getResponse((GetAttributeValuesByFqnsRequest) invocation.getArguments()[0]);
+                        SettableFuture<GetAttributeValuesByFqnsResponse> future = SettableFuture.create();
+                        future.set(resp);  // Set the request as the future's result
+                        return future;
+                });
+
+                Granter reasoner = Autoconfigure.newGranterFromService(attributeGrpcStub, new KASKeyCache(), tc.getPolicy().toArray(new AttributeValueFQN[0]));
+                assertThat(reasoner).isNotNull();
+
+                var wrapper = new Object(){ int i = 0; };
+                List<KeySplitStep> plan = reasoner.plan(tc.getDefaults(), () -> {
+                    return String.valueOf(wrapper.i++ + 1);
+                }
+                
+                );
+                assertThat(plan.size()).isEqualTo(tc.getPlan().size());
+                assertThat(plan).hasSameElementsAs(tc.getPlan());
+            }
+            );
+        }
+    }
+
 
     private static class TestCase {
         private final String n;
@@ -520,6 +677,16 @@ private static class ReasonerTestCase {
             this.plan = plan;
         }
 
+        ReasonerTestCase(String name, List<AttributeValueFQN> policy, List<String> defaults, List<KeySplitStep> plan) {
+            this.name = name;
+            this.policy = policy;
+            this.defaults = defaults;
+            this.plan = plan;
+            this.ats = null;
+            this.keyed = null;
+            this.reduced = null;
+        }
+
         public String getN() {
             return name;
         }
@@ -659,4 +826,90 @@ void testStoreKeysToCache_MultipleKasEntries() {
         assertEquals("rsa:2048", storedKASInfo2.Algorithm);
         assertEquals("public-key-pem-2", storedKASInfo2.PublicKey);
     }
+
+
+    GetAttributeValuesByFqnsResponse getResponseWithGrants(GetAttributeValuesByFqnsRequest req, List<KeyAccessServer> grants) {
+        GetAttributeValuesByFqnsResponse.Builder builder = GetAttributeValuesByFqnsResponse.newBuilder();
+
+        for (String v : req.getFqnsList()) {
+            AttributeValueFQN vfqn;
+            try {
+                vfqn = new AttributeValueFQN(v);
+            } catch (Exception e) {
+                return null;  // Or throw the exception as needed
+            }
+
+            Value val = Value.newBuilder(mockValueFor(vfqn)).addAllGrants(grants).build();
+            
+            builder.putFqnAttributeValues(v, GetAttributeValuesByFqnsResponse.AttributeAndValue.newBuilder()
+            .setAttribute(val.getAttribute())
+            .setValue(val)
+            .build());
+        }
+
+        return builder.build();
+    }
+
+
+    @Test
+    void testKeyCacheFromGrants() throws InterruptedException, ExecutionException {
+        // Create the KasPublicKey object
+        KasPublicKey kasPublicKey1 = KasPublicKey.newBuilder()
+            .setAlg(KasPublicKeyAlgEnum.KAS_PUBLIC_KEY_ALG_ENUM_EC_SECP256R1)
+            .setKid("test-kid")
+            .setPem("public-key-pem")
+            .build();
+        KasPublicKey kasPublicKey2 = KasPublicKey.newBuilder()
+            .setAlg(KasPublicKeyAlgEnum.KAS_PUBLIC_KEY_ALG_ENUM_RSA_2048)
+            .setKid("test-kid-2")
+            .setPem("public-key-pem-2")
+            .build();
+
+        // Add the KasPublicKey to a list
+        List<KasPublicKey> kasPublicKeys = new ArrayList<>();
+        kasPublicKeys.add(kasPublicKey1);
+        kasPublicKeys.add(kasPublicKey2);
+
+        // Create the KeyAccessServer object
+        KeyAccessServer kas1 = KeyAccessServer.newBuilder()
+            .setPublicKey(PublicKey.newBuilder()
+                .setCached(KasPublicKeySet.newBuilder()
+                    .addAllKeys(kasPublicKeys)
+                    .build())
+            )
+            .setUri("https://example.com/kas")
+            .build();
+        
+        AttributesServiceGrpc.AttributesServiceFutureStub attributeGrpcStub = mock(AttributesServiceGrpc.AttributesServiceFutureStub.class);
+        lenient().when(attributeGrpcStub.getAttributeValuesByFqns(any(GetAttributeValuesByFqnsRequest.class))).thenAnswer(
+            invocation -> {
+                GetAttributeValuesByFqnsResponse resp = getResponseWithGrants((GetAttributeValuesByFqnsRequest) invocation.getArguments()[0], List.of(kas1));
+                SettableFuture<GetAttributeValuesByFqnsResponse> future = SettableFuture.create();
+                future.set(resp);  // Set the request as the future's result
+                return future;
+        });
+
+        KASKeyCache keyCache = new KASKeyCache();
+
+        Granter reasoner = Autoconfigure.newGranterFromService(attributeGrpcStub, keyCache, List.of(clsS, rel2gbr, rel2usa, n2kHCS, n2kSI).toArray(new AttributeValueFQN[0]));
+        assertThat(reasoner).isNotNull();
+
+        // Verify that the key was stored in the cache
+        Config.KASInfo storedKASInfo = keyCache.get("https://example.com/kas", "ec:secp256r1");
+        assertNotNull(storedKASInfo);
+        assertEquals("https://example.com/kas", storedKASInfo.URL);
+        assertEquals("test-kid", storedKASInfo.KID);
+        assertEquals("ec:secp256r1", storedKASInfo.Algorithm);
+        assertEquals("public-key-pem", storedKASInfo.PublicKey);
+
+        Config.KASInfo storedKASInfo2 = keyCache.get("https://example.com/kas", "rsa:2048");
+        assertNotNull(storedKASInfo2);
+        assertEquals("https://example.com/kas", storedKASInfo2.URL);
+        assertEquals("test-kid-2", storedKASInfo2.KID);
+        assertEquals("rsa:2048", storedKASInfo2.Algorithm);
+        assertEquals("public-key-pem-2", storedKASInfo2.PublicKey);
+
+    }
+
+    
 }

From 64c76c39398427246b599c8687e8e84e8d8d9a69 Mon Sep 17 00:00:00 2001
From: Elizabeth Healy <ehealy@virtru.com>
Date: Thu, 22 Aug 2024 11:33:20 -0400
Subject: [PATCH 23/23] change to 5 mins

---
 sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java    | 4 ++--
 .../test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java    | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java
index b6e4eb78..3d4c5988 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASKeyCache.java
@@ -25,8 +25,8 @@ public Config.KASInfo get(String url, String algorithm) {
             return null;
         }
 
-        LocalDateTime anHourAgo = now.minus(1, ChronoUnit.HOURS);
-        if (anHourAgo.isAfter(cachedValue.timestamp)) {
+        LocalDateTime aMinAgo = now.minus(5, ChronoUnit.MINUTES);
+        if (aMinAgo.isAfter(cachedValue.timestamp)) {
             cache.remove(cacheKey);
             return null;
         }
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java
index ec371726..5550678a 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/KASKeyCacheTest.java
@@ -52,7 +52,7 @@ void testStoreAndGet_AfterTimeLimit() {
 
         // Simulate time passing by modifying the timestamp directly
         KASKeyRequest cacheKey = new KASKeyRequest("https://example.com/kas1", "rsa:2048");
-        TimeStampedKASInfo timeStampedKASInfo = new TimeStampedKASInfo(kasInfo1, LocalDateTime.now().minus(2, ChronoUnit.HOURS));
+        TimeStampedKASInfo timeStampedKASInfo = new TimeStampedKASInfo(kasInfo1, LocalDateTime.now().minus(6, ChronoUnit.MINUTES));
         kasKeyCache.cache.put(cacheKey, timeStampedKASInfo);
 
         // Attempt to retrieve the item after the time limit
