From 0be0b332b8b66ce84830d879de95871749a2d653 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 9 Nov 2026 09:09:04 -0500 Subject: [PATCH] fix(sdk,cmdline): DSPX-1561 pass the default assertion verification key to the SDK The decrypt command's assertion verification keys file now converts defaultKey (or DefaultKey) the same way as per-ID keys: an HS256 key is the UTF-8 bytes of its string and an RS256 key is a PEM public key. The SDK already verifies an assertion with no per-ID key with the default key, but the CLI passed it on as unconverted text. A keys file that cannot be used as written is reported with reason=configuration-error rather than leaving a key out: an unknown top-level member, an alg other than RS256 or HS256, a key with members but no alg, a key that cannot be read, or PEM text as an HS256 secret. A per-ID entry that is null or empty stays an entry, so verifying that assertion fails instead of falling back to another key. Document, in the SDK javadoc and the decrypt command's help, that a default key applies to every assertion without a per-ID key, including payload-key assertions such as the system-metadata assertion the Java writer adds, so those fail verification; point to per-ID keys instead. Signed-off-by: Dave Mihalcik --- .../java/io/opentdf/platform/Command.java | 185 ++++++++++++++---- .../AssertionVerificationKeysTest.java | 173 ++++++++++++++++ .../java/io/opentdf/platform/sdk/Config.java | 23 +++ 3 files changed, 345 insertions(+), 36 deletions(-) create mode 100644 cmdline/src/test/java/io/opentdf/platform/AssertionVerificationKeysTest.java diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index c4953843e..81f4183fe 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -7,6 +7,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; @@ -179,7 +180,7 @@ public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.refl } } - private Gson buildGson() { + private static Gson buildGson() { return new GsonBuilder() .registerTypeAdapter(AssertionConfig.AssertionKey.class, new AssertionKeyDeserializer()) .create(); @@ -217,7 +218,7 @@ void setVerbose(boolean verbose) { @Option(names = { "-p", "--platform-endpoint" }) private String platformEndpoint; - private Object correctKeyType(AssertionConfig.AssertionKeyAlg alg, Object key, boolean publicKey) + private static Object correctKeyType(AssertionConfig.AssertionKeyAlg alg, Object key, boolean publicKey) throws RuntimeException { if (alg == AssertionConfig.AssertionKeyAlg.HS256) { if (key instanceof String) { @@ -397,7 +398,13 @@ void decrypt( @Option(names = { "--with-assertion-verification-disabled" }, defaultValue = "false") boolean disableAssertionVerification, @Option(names = { - "--with-assertion-verification-keys" }, defaultValue = Option.NULL_VALUE) Optional assertionVerification, + "--with-assertion-verification-keys" }, defaultValue = Option.NULL_VALUE, description = "JSON, or the " + + "path of a JSON file, giving the keys assertions are verified with: " + + "{\"keys\": {\"\": {\"alg\": \"RS256\", \"key\": \"\"}}, " + + "\"defaultKey\": {\"alg\": \"HS256\", \"key\": \"\"}}. " + + "Each assertion uses its per-ID key, else the default key, else the payload key. " + + "A default key also applies to payload-key assertions, such as the system-metadata " + + "assertion this tool writes, so those fail verification; set per-ID keys instead.") Optional assertionVerification, @Option(names = { "--kas-allowlist" }, defaultValue = Option.NULL_VALUE) Optional kasAllowlistStr, @Option(names = { "--ignore-kas-allowlist" }, defaultValue = Option.NULL_VALUE) Optional ignoreAllowlist) @@ -405,39 +412,8 @@ void decrypt( try (var sdk = buildSDK()) { var opts = new ArrayList>(); if (assertionVerification.isPresent()) { - var assertionVerificationInput = assertionVerification.get(); - Gson gson = buildGson(); - - Config.AssertionVerificationKeys assertionVerificationKeys; - try { - assertionVerificationKeys = gson.fromJson(assertionVerificationInput, - Config.AssertionVerificationKeys.class); - } catch (JsonSyntaxException e) { - // try it as a file path - try { - String fileJson = Files.readString(Paths.get(assertionVerificationInput)); - assertionVerificationKeys = gson.fromJson(fileJson, - Config.AssertionVerificationKeys.class); - } catch (JsonSyntaxException e2) { - throw new RuntimeException("Failed to parse assertion verification keys from file", e2); - } catch (Exception e3) { - throw new RuntimeException( - "Could not parse assertion verification keys as json string or path to file", - e3); - } - } - - for (Map.Entry entry : assertionVerificationKeys.keys - .entrySet()) { - try { - Object correctedKey = correctKeyType(entry.getValue().alg, entry.getValue().key, true); - entry.setValue(new AssertionConfig.AssertionKey(entry.getValue().alg, correctedKey)); - } catch (Exception e) { - throw new RuntimeException("Error with assertion verification key: " + e.getMessage(), - e); - } - } - opts.add(Config.withAssertionVerificationKeys(assertionVerificationKeys)); + opts.add(Config.withAssertionVerificationKeys( + parseAssertionVerificationKeys(assertionVerification.get()))); } if (disableAssertionVerification) { @@ -462,6 +438,143 @@ void decrypt( } } + /** + * Reads the assertion verification keys, given as JSON or as the path of a JSON file: + *
+     * { "keys": { "<assertion id>": { "alg": "RS256", "key": "<PEM public key>" } },
+     *   "defaultKey": { "alg": "HS256", "key": "<secret>" } }
+     * 
+ * {@code DefaultKey} is accepted for {@code defaultKey}. An HS256 key is the UTF-8 bytes of + * its string, and must not be PEM text; an RS256 key is a PEM public key. + *

+ * Anything that cannot be used as configured is an error with + * {@code reason=configuration-error}, never a key that silently goes missing: an unknown + * top-level member, an {@code alg} other than {@code RS256} or {@code HS256}, or a key with + * members but no {@code alg}. Only an absent, null or empty {@code defaultKey} means none. + *

+ * A default key applies to every assertion without a per-ID key, including payload-key + * assertions such as the {@code system-metadata} assertion this tool writes, so those fail + * verification; use per-ID keys for files that carry them. + */ + static Config.AssertionVerificationKeys parseAssertionVerificationKeys(String jsonOrPath) { + JsonObject root = readKeysJson(jsonOrPath); + var assertionVerificationKeys = new Config.AssertionVerificationKeys(); + String defaultKeyName = null; + JsonElement defaultKey = null; + for (Map.Entry member : root.entrySet()) { + switch (member.getKey()) { + case "keys": + readPerIdKeys(member.getValue(), assertionVerificationKeys.keys); + break; + case "defaultKey": + case "DefaultKey": + if (defaultKeyName != null) { + throw keysFileError("both " + defaultKeyName + " and " + member.getKey() + " are given"); + } + defaultKeyName = member.getKey(); + defaultKey = member.getValue(); + break; + default: + throw keysFileError("unknown member \"" + member.getKey() + "\""); + } + } + if (defaultKey != null && !defaultKey.isJsonNull()) { + if (!defaultKey.isJsonObject()) { + throw keysFileError("the default key is not an object"); + } + if (defaultKey.getAsJsonObject().size() > 0) { + assertionVerificationKeys.defaultKey = readKey(defaultKey.getAsJsonObject(), "default key"); + } + } + return assertionVerificationKeys; + } + + private static JsonObject readKeysJson(String jsonOrPath) { + JsonElement json; + try { + json = JsonParser.parseString(jsonOrPath); + } catch (JsonParseException e) { + json = null; + } + if (json == null || !json.isJsonObject()) { + // try it as a file path + String fileJson; + try { + fileJson = Files.readString(Paths.get(jsonOrPath)); + } catch (Exception e) { + throw new RuntimeException( + "Could not parse assertion verification keys as json string or path to file", e); + } + try { + json = JsonParser.parseString(fileJson); + } catch (JsonParseException e) { + throw new RuntimeException("Failed to parse assertion verification keys from file", e); + } + if (!json.isJsonObject()) { + throw keysFileError("the keys file is not a JSON object"); + } + } + return json.getAsJsonObject(); + } + + private static void readPerIdKeys(JsonElement keys, Map into) { + if (keys == null || keys.isJsonNull()) { + return; + } + if (!keys.isJsonObject()) { + throw keysFileError("keys is not an object"); + } + for (Map.Entry entry : keys.getAsJsonObject().entrySet()) { + JsonElement key = entry.getValue(); + if (key.isJsonNull()) { + // an empty entry, which the SDK rejects if it is selected; never a missing one, + // which would leave the default key or the payload key to verify that assertion + into.put(entry.getKey(), new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, null)); + } else if (key.isJsonObject()) { + into.put(entry.getKey(), readKey(key.getAsJsonObject(), "assertion " + entry.getKey())); + } else { + throw keysFileError("the key for assertion " + entry.getKey() + " is not an object"); + } + } + } + + private static AssertionConfig.AssertionKey readKey(JsonObject json, String which) { + JsonElement alg = json.get("alg"); + if (alg == null || alg.isJsonNull()) { + if (json.entrySet().stream().anyMatch(member -> !member.getValue().isJsonNull())) { + throw keysFileError("the " + which + " has no alg"); + } + // nothing set: an empty per-ID entry, which the SDK rejects if it is selected + return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, null); + } + boolean supported = alg.isJsonPrimitive() && alg.getAsJsonPrimitive().isString() + && ("RS256".equals(alg.getAsString()) || "HS256".equals(alg.getAsString())); + if (!supported) { + throw keysFileError("the " + which + " has an unsupported alg " + alg); + } + AssertionConfig.AssertionKey key; + try { + key = buildGson().fromJson(json, AssertionConfig.AssertionKey.class); + } catch (JsonParseException e) { + throw keysFileError("the " + which + " cannot be read: " + e.getMessage()); + } + try { + key.key = correctKeyType(key.alg, key.key, true); + } catch (Exception e) { + throw keysFileError("the " + which + " cannot be read: " + e.getMessage()); + } + if (key.key instanceof byte[] + && new String((byte[]) key.key, StandardCharsets.UTF_8).contains("-----BEGIN")) { + throw keysFileError("the " + which + " is HS256 but holds PEM text"); + } + return key; + } + + private static IllegalArgumentException keysFileError(String detail) { + return new IllegalArgumentException( + "invalid assertion verification keys: reason=configuration-error: " + detail); + } + // With an output file, write to a sibling temp file and move it into place only on // success, so a failed decrypt (e.g. an offset past the end) leaves no file behind. private interface PlaintextWriter { diff --git a/cmdline/src/test/java/io/opentdf/platform/AssertionVerificationKeysTest.java b/cmdline/src/test/java/io/opentdf/platform/AssertionVerificationKeysTest.java new file mode 100644 index 000000000..60b8492df --- /dev/null +++ b/cmdline/src/test/java/io/opentdf/platform/AssertionVerificationKeysTest.java @@ -0,0 +1,173 @@ +package io.opentdf.platform; + +import com.google.gson.JsonObject; +import io.opentdf.platform.sdk.AssertionConfig; +import io.opentdf.platform.sdk.Config; +import io.opentdf.platform.sdk.CryptoUtils; +import io.opentdf.platform.sdk.Manifest; +import io.opentdf.platform.sdk.SDKException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The decrypt command's {@code --with-assertion-verification-keys} file: per-ID keys and the + * default key are both converted to SDK key material, so a default key works from the CLI. + */ +class AssertionVerificationKeysTest { + + private static final String HS256_SECRET = Base64.getEncoder().encodeToString(new byte[32]).replace('A', 'k'); + + private static JsonObject key(String alg, String key) { + var json = new JsonObject(); + json.addProperty("alg", alg); + json.addProperty("key", key); + return json; + } + + private static String keysFile(JsonObject perId, JsonObject defaultKey) { + var json = new JsonObject(); + if (perId != null) { + json.add("keys", perId); + } + if (defaultKey != null) { + json.add("defaultKey", defaultKey); + } + return json.toString(); + } + + /** An assertion signed with {@code signingKey}, the way the SDK writer signs it. */ + private static Manifest.Assertion signedAssertion(AssertionConfig.AssertionKey signingKey) throws Exception { + var assertion = new Manifest.Assertion(); + assertion.id = "assertion1"; + assertion.type = "handling"; + assertion.scope = "payload"; + assertion.statement = new AssertionConfig.Statement(); + assertion.statement.format = "string"; + assertion.statement.value = "SECRET"; + assertion.sign(new Manifest.Assertion.HashValues(assertion.hash(), "c2ln"), signingKey); + return assertion; + } + + @Test + void anHs256DefaultKeyIsTheUtf8BytesOfItsString() throws Exception { + var keys = Command.parseAssertionVerificationKeys(keysFile(null, key("HS256", HS256_SECRET))); + assertThat(keys.keys).isEmpty(); + assertThat(keys.defaultKey.alg).isEqualTo(AssertionConfig.AssertionKeyAlg.HS256); + assertThat(keys.defaultKey.key).isEqualTo(HS256_SECRET.getBytes(StandardCharsets.UTF_8)); + + var assertion = signedAssertion(new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, + HS256_SECRET.getBytes(StandardCharsets.UTF_8))); + assertThat(assertion.verify(keys.defaultKey).getAssertionHash()).isEqualTo(assertion.hash()); + } + + @Test + void anRs256DefaultKeyIsAPemPublicKey() throws Exception { + KeyPair signer = CryptoUtils.generateRSAKeypair(); + var keys = Command.parseAssertionVerificationKeys( + keysFile(null, key("RS256", CryptoUtils.getPublicKeyPEM(signer.getPublic())))); + assertThat(keys.defaultKey.key).isInstanceOf(RSAPublicKey.class).isEqualTo(signer.getPublic()); + + var assertion = signedAssertion( + new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, signer.getPrivate())); + assertThat(assertion.verify(keys.defaultKey).getAssertionHash()).isEqualTo(assertion.hash()); + + // and an unrelated default does not verify it + var other = Command.parseAssertionVerificationKeys(keysFile(null, + key("RS256", CryptoUtils.getPublicKeyPEM(CryptoUtils.generateRSAKeypair().getPublic())))); + assertThatThrownBy(() -> assertion.verify(other.defaultKey)).isInstanceOf(SDKException.class); + } + + @Test + void perIdKeysAndTheDefaultAreReadTogetherFromAFile(@TempDir Path tempDir) throws Exception { + KeyPair signer = CryptoUtils.generateRSAKeypair(); + var perId = new JsonObject(); + perId.add("assertion1", key("RS256", CryptoUtils.getPublicKeyPEM(signer.getPublic()))); + Path file = Files.writeString(tempDir.resolve("keys.json"), keysFile(perId, key("HS256", HS256_SECRET))); + + Config.AssertionVerificationKeys keys = Command.parseAssertionVerificationKeys(file.toString()); + assertThat(keys.keys.get("assertion1").key).isEqualTo(signer.getPublic()); + assertThat(keys.defaultKey.key).isEqualTo(HS256_SECRET.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void theDefaultKeyMayBeSpelledDefaultKey() { + var json = new JsonObject(); + json.add("DefaultKey", key("HS256", HS256_SECRET)); + var keys = Command.parseAssertionVerificationKeys(json.toString()); + assertThat(keys.defaultKey.key).isEqualTo(HS256_SECRET.getBytes(StandardCharsets.UTF_8)); + } + + @ParameterizedTest + @ValueSource(strings = { + // an alg Gson would read as null, which must not make the key absent + "{'defaultKey':{'alg':'rs256','pem':'x'}}", + "{'defaultKey':{'alg':'ES256'}}", + "{'defaultKey':{'alg':'NotDefined','key':'x'}}", + "{'defaultKey':{'alg':5,'key':'x'}}", + "{'defaultKey':{'key':'x'}}", + "{'defaultKey':{'pem':'x'}}", + "{'keys':{'a':{'alg':'RS512','key':'x'}}}", + "{'keys':{'a':{'key':'x'}}}", + "{'keys':{'a':'x'}}", + "{'keys':['x']}", + "{'defaultKey':'x'}", + // unknown top-level members, such as a misspelled default key + "{'default_key':{'alg':'HS256','key':'x'}}", + "{'keys':{},'extra':1}", + "{'defaultKey':{'alg':'HS256','key':'kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk'}," + + "'DefaultKey':{'alg':'HS256','key':'kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk'}}", + }) + void keysThatCannotBeUsedAsGivenAreConfigurationErrors(String singleQuoted) { + assertThatThrownBy(() -> Command.parseAssertionVerificationKeys(singleQuoted.replace('\'', '"'))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reason=configuration-error"); + } + + @Test + void pemTextIsNeverAnHs256Secret() { + String pem = CryptoUtils.getPublicKeyPEM(CryptoUtils.generateRSAKeypair().getPublic()); + assertThatThrownBy(() -> Command.parseAssertionVerificationKeys(keysFile(null, key("HS256", pem)))) + .hasMessageContaining("reason=configuration-error") + .hasMessageContaining("PEM"); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "{'defaultKey':null}", "{'defaultKey':{}}", "{'keys':null}", "{'keys':{}}"}) + void noDefaultKeyMeansNone(String singleQuoted) { + var keys = Command.parseAssertionVerificationKeys(singleQuoted.replace('\'', '"')); + assertThat(keys.defaultKey).isNull(); + assertThat(keys.keys).isEmpty(); + } + + @Test + void anUnreadableKeyIsAConfigurationError() { + assertThatThrownBy(() -> Command.parseAssertionVerificationKeys(keysFile(null, key("RS256", "not a pem")))) + .hasMessageContaining("reason=configuration-error") + .hasMessageContaining("default key"); + } + + @Test + void anEmptyPerIdEntryIsLeftForTheSdkToReject() throws Exception { + var keys = Command.parseAssertionVerificationKeys("{\"keys\":{\"assertion1\":null,\"assertion2\":{}}}"); + assertThat(keys.keys).containsKeys("assertion1", "assertion2"); + for (var entry : keys.keys.values()) { + assertThat(entry).isNotNull(); + var assertion = signedAssertion(new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, + HS256_SECRET.getBytes(StandardCharsets.UTF_8))); + assertThatThrownBy(() -> assertion.verify(entry)) + .isInstanceOf(SDKException.class); + } + } +} 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 f4dbc4f5a..5ce96bbff 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java @@ -106,8 +106,25 @@ public static KASInfo fromSimpleKasKey(SimpleKasKey ki) { } } + /** + * The keys a reader verifies assertion bindings with. Each assertion uses the entry in + * {@link #keys} for its exact {@code id} if there is one, otherwise {@link #defaultKey} if it + * is set, otherwise the TDF's payload key with HS256. + *

+ * RS256 keys are {@link java.security.interfaces.RSAPublicKey}s or RSA + * {@link com.nimbusds.jose.jwk.JWK}s; HS256 keys are {@code byte[]} secrets. + */ public static class AssertionVerificationKeys { + /** + * Verifies every assertion that has no entry in {@link #keys}. Null means none. + *

+ * That includes assertions bound with the payload key, such as the + * {@code system-metadata} assertion this SDK's writer adds, so a TDF that carries one + * fails verification when a default key is set. To verify those alongside signed + * assertions, configure a key per assertion id in {@link #keys} instead of a default key. + */ public AssertionConfig.AssertionKey defaultKey; + /** Per-assertion keys, by exact assertion {@code id}. */ public Map keys = new HashMap<>(); Boolean isEmpty() { @@ -145,6 +162,12 @@ public static TDFReaderConfig newTDFReaderConfig(Consumer... op return config; } + /** + * The keys assertion bindings are verified with. See {@link AssertionVerificationKeys}: each + * assertion uses its per-ID key, else the default key, else the payload key. A default key + * also applies to payload-key assertions such as {@code system-metadata}, which then fail; + * use per-ID keys where a TDF carries both kinds. + */ public static Consumer withAssertionVerificationKeys( AssertionVerificationKeys assertionVerificationKeys) { return (TDFReaderConfig config) -> config.assertionVerificationKeys = assertionVerificationKeys;