diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index c4953843..81f4183f 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 00000000..60b8492d --- /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 f4dbc4f5..5ce96bbf 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;