From 11ce258d4a18c8d2fc60e08b6e826d704ee7c254 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 9 Nov 2026 09:16:03 -0500 Subject: [PATCH] fix(sdk): DSPX-1561 hash assertions as received Each assertion keeps the JSON object it was parsed from. assertionHash is computed over that object minus only its top-level binding, as Section 4 of the ZTDF assertions specification defines it, never over a re-serialization of the typed model, so members this SDK does not model and the JSON type of statement.value are covered. The object is written back unchanged when a manifest is serialized; changing a typed field replaces only that member. The id and binding members are read leniently, so that verification, not parsing, reports a bad value. Writers hash the wire form they write, emit binding method "jws", and refuse, as a configuration error, an assertion that a reader would reject while parsing or that has no id. Add the public Manifest.Assertion.computeAssertionHash(String), which applies the Section 3 parsing rules to the raw JSON of one assertion and returns its hash. A recomputed hash or assertionSig that does not match the signed one is reported as an AssertionFailureException with binding-mismatch. Signed-off-by: Dave Mihalcik --- .../opentdf/platform/sdk/AssertionJson.java | 95 +++++- .../io/opentdf/platform/sdk/Manifest.java | 238 ++++++++++++++- .../java/io/opentdf/platform/sdk/TDF.java | 16 +- .../platform/sdk/AssertionHashTest.java | 278 ++++++++++++++++++ .../sdk/StrictManifestParsingTest.java | 75 ++++- .../java/io/opentdf/platform/sdk/TDFTest.java | 68 +++++ 6 files changed, 748 insertions(+), 22 deletions(-) create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/AssertionHashTest.java diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java index 4a58f7cb..377e95d3 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java @@ -1,19 +1,58 @@ package io.opentdf.platform.sdk; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonToken; +import org.apache.commons.codec.binary.Hex; +import org.erdtman.jcs.JsonCanonicalizer; import java.io.IOException; import java.io.Reader; +import java.io.StringReader; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.Objects; /** - * Parsing of assertion wire objects, as Section 3 of the ZTDF assertions specification defines - * it. + * Parsing and hashing of assertion wire objects, as Sections 3 and 4 of the ZTDF assertions + * specification define them. */ final class AssertionJson { + /** Gson's own JSON tree adapter; it reads through whatever reader it is handed. */ + static final TypeAdapter JSON_ELEMENT = new Gson().getAdapter(JsonElement.class); + + private static final String BINDING = "binding"; + private AssertionJson() { } + /** + * Parses the raw JSON text of one assertion wire object under Section 3: no duplicate member + * names anywhere in it, no case variants of the modelled names at the assertion, statement + * and binding levels, and I-JSON content throughout. + * + * @throws SDK.AssertionFailureException if the text is not such an object + */ + static JsonObject parseAssertion(String wireJson) { + Objects.requireNonNull(wireJson, "wireJson"); + try (StrictJsonReader reader = new StrictJsonReader(new StringReader(wireJson), StrictJsonReader.Mode.ASSERTION)) { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw invalidJson("an assertion must be a JSON object"); + } + JsonObject assertion = JSON_ELEMENT.read(reader).getAsJsonObject(); + if (reader.peek() != JsonToken.END_DOCUMENT) { + throw invalidJson("unexpected content after the assertion object"); + } + return assertion; + } catch (IOException | IllegalStateException | NumberFormatException e) { + throw invalidJson("the assertion is not valid JSON"); + } + } + /** * Applies only the manifest-level checks of Section 3 to a whole manifest, without building * the typed manifest. Malformed JSON is reported as {@code invalid-json} here; the manifest @@ -32,6 +71,58 @@ static void checkManifest(Reader manifestJson) { } } + /** + * Parses any JSON value, rejecting duplicate member names but applying none of the + * assertion-specific rules. Used for JWS headers and claims. + * + * @throws IOException or a {@link SDK.AssertionFailureException} if the text is not strict JSON + */ + static JsonElement parseStrict(String json) throws IOException { + try (StrictJsonReader reader = new StrictJsonReader(new StringReader(json), StrictJsonReader.Mode.GENERIC)) { + JsonElement element = JSON_ELEMENT.read(reader); + if (reader.peek() != JsonToken.END_DOCUMENT) { + throw new IOException("unexpected content after the JSON value"); + } + return element; + } catch (IllegalStateException | NumberFormatException e) { + throw new IOException("not valid JSON", e); + } + } + + /** + * {@code assertionHash} as Section 4 defines it: the lowercase hex SHA-256 of the JCS (RFC + * 8785) form of the assertion with only its top-level {@code binding} member removed. + * Everything else, including members this SDK does not model and the JSON type of + * {@code statement.value}, is hashed exactly as given. + * + * @param assertion an assertion that has passed {@link #parseAssertion} or the manifest reader + */ + static String hash(JsonObject assertion) { + JsonObject withoutBinding = new JsonObject(); + for (Map.Entry member : assertion.entrySet()) { + if (!BINDING.equals(member.getKey())) { + withoutBinding.add(member.getKey(), member.getValue()); + } + } + byte[] canonical; + try { + // JsonElement.toString writes JSON nulls and keeps each number's original text, which + // the canonicalizer then reads as an IEEE 754 double, as RFC 8785 requires + canonical = new JsonCanonicalizer(withoutBinding.toString()).getEncodedUTF8(); + } catch (IOException e) { + throw invalidJson("the assertion cannot be canonicalized: " + e.getMessage()); + } + return Hex.encodeHexString(sha256(canonical)); + } + + private static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (NoSuchAlgorithmException e) { + throw new SDKException("error creating SHA-256 message digest", e); + } + } + private static SDK.AssertionFailureException invalidJson(String detail) { return new SDK.AssertionFailureException(null, AssertionFailureReason.INVALID_JSON, detail); } 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 49bd6dd3..76426857 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java @@ -30,14 +30,11 @@ import com.nimbusds.jose.crypto.MACVerifier; import com.nimbusds.jose.crypto.RSASSASigner; import com.nimbusds.jose.crypto.RSASSAVerifier; -import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.util.X509CertUtils; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import io.opentdf.platform.sdk.SDK.AssertionException; -import org.apache.commons.codec.binary.Hex; -import org.erdtman.jcs.JsonCanonicalizer; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -50,8 +47,6 @@ import java.lang.reflect.Type; import java.nio.charset.CharacterCodingException; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.cert.X509Certificate; @@ -925,6 +920,18 @@ public int hashCode() { } } + /** + * An assertion in the manifest. + *

+ * An assertion read from a manifest keeps the JSON object it was parsed from. That object, + * and not these typed fields, is what is hashed and verified, and what is written back when + * the manifest is serialized, so members this SDK does not model and the JSON type of + * {@code statement.value} survive. The typed fields are a convenience view of it: object + * statement values, for example, appear in {@link AssertionConfig.Statement#value} as JSON + * text. Changing a typed field changes that member, and only that member, in the serialized + * form and in {@link #hash()}. + */ + @JsonAdapter(AssertionTypeAdapter.class) static public class Assertion { public String id; public String type; @@ -932,6 +939,10 @@ static public class Assertion { public String appliesToState; public AssertionConfig.Statement statement; public Binding binding; + + /** The object this assertion was parsed from, never modified; null if built in code. */ + private transient JsonObject received; + static public class HashValues { private final String assertionHash; private final String signature; @@ -967,17 +978,169 @@ public int hashCode() { return Objects.hash(id, type, scope, appliesToState, statement, binding); } + /** + * Computes {@code assertionHash} for the raw JSON text of one assertion wire object, as + * Section 4 of the ZTDF assertions specification defines it: the lowercase hex SHA-256 + * of the RFC 8785 (JCS) canonical form of the object with only its top-level + * {@code binding} member removed. No other member is dropped or reinterpreted. + *

+ * The text is first parsed under Section 3 of the specification. Use this to check an + * assertion's binding outside the SDK; it does not check the {@code id} or the binding. + * + * @param wireJson the assertion object exactly as it appears in a manifest + * @return 64 lowercase hexadecimal characters + * @throws SDK.AssertionFailureException if the text has duplicate member names, case + * variants of modelled member names, content that is + * not I-JSON, or is not a JSON object at all + */ + public static String computeAssertionHash(String wireJson) { + return AssertionJson.hash(AssertionJson.parseAssertion(wireJson)); + } + + /** + * The {@code assertionHash} of this assertion as it would be written to a manifest now: + * for an unmodified assertion read from a manifest, the hash of the assertion as + * received; after a typed field has been changed, the hash with that change. Reading a + * TDF never uses this: {@code loadTDF} verifies each assertion exactly as received. + * + * @throws SDK.AssertionFailureException if the assertion cannot be represented as I-JSON + */ public String hash() throws IOException { - MessageDigest digest; + return computeAssertionHash(toJsonTree().toString()); + } + + /** + * The hash a writer signs, after checking that the wire form would pass the reader's + * Section 3 parsing and id check. + * + * @throws IllegalArgumentException if it would not; a writer-side configuration error, + * not a tamper error + */ + String hashForWriting() { + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("an assertion cannot be written without an id: reason=" + + AssertionFailureReason.INVALID_ID.code()); + } try { - digest = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new SDKException("error creating SHA-256 message digest", e); + return computeAssertionHash(toJsonTree().toString()); + } catch (SDK.AssertionFailureException e) { + throw new IllegalArgumentException("assertion " + id + " cannot be written: reason=" + + e.getReason().code()); + } + } + + /** Reads an assertion from the object parsed out of a manifest. */ + static Assertion fromJson(JsonObject json) { + Assertion assertion = new Assertion(); + assertion.received = json; + TypedView view = TypedView.of(json); + assertion.id = view.id; + assertion.type = view.type; + assertion.scope = view.scope; + assertion.appliesToState = view.appliesToState; + assertion.statement = view.statement; + assertion.binding = view.binding; + return assertion; + } + + /** The object this assertion was parsed from, or null if it was built in code. Do not modify it. */ + JsonObject received() { + return received; + } + + /** + * The wire form of this assertion: the object it was parsed from, with any typed field + * that has since been changed written over the corresponding member, or, for an assertion + * built in code, an object holding its non-null typed fields. + */ + JsonObject toJsonTree() { + JsonObject json = received == null ? new JsonObject() : received.deepCopy(); + TypedView original = received == null ? TypedView.EMPTY : TypedView.of(received); + overlay(json, "id", id, original.id); + overlay(json, "type", type, original.type); + overlay(json, "scope", scope, original.scope); + overlay(json, "appliesToState", appliesToState, original.appliesToState); + if (!Objects.equals(statement, original.statement)) { + if (statement == null) { + json.remove("statement"); + } else { + JsonObject target = original.statement == null + ? new JsonObject() + : json.getAsJsonObject("statement"); + overlay(target, "format", statement.format, original.statement == null ? null : original.statement.format); + overlay(target, "schema", statement.schema, original.statement == null ? null : original.statement.schema); + overlay(target, "value", statement.value, original.statement == null ? null : original.statement.value); + json.add("statement", target); + } + } + if (!Objects.equals(binding, original.binding)) { + if (binding == null) { + json.remove("binding"); + } else { + JsonObject target = original.binding == null ? new JsonObject() : json.getAsJsonObject("binding"); + overlay(target, "method", binding.method, original.binding == null ? null : original.binding.method); + overlay(target, "signature", binding.signature, original.binding == null ? null : original.binding.signature); + json.add("binding", target); + } + } + return json; + } + + private static void overlay(JsonObject json, String member, String value, String original) { + if (Objects.equals(value, original)) { + return; + } + if (value == null) { + json.remove(member); + } else { + json.addProperty(member, value); + } + } + + /** The typed fields as they are read from an assertion object. */ + private static final class TypedView { + static final TypedView EMPTY = new TypedView(); + + String id; + String type; + String scope; + String appliesToState; + AssertionConfig.Statement statement; + Binding binding; + + static TypedView of(JsonObject json) { + TypedView view = new TypedView(); + // only a JSON string is an id; anything else fails verification with invalid-id + view.id = stringMember(json, "id"); + view.type = textMember(json, "type"); + view.scope = textMember(json, "scope"); + view.appliesToState = textMember(json, "appliesToState"); + JsonElement statement = json.get("statement"); + if (statement != null && !statement.isJsonNull()) { + view.statement = STATEMENT_ADAPTER.deserialize(statement, AssertionConfig.Statement.class, null); + } + JsonElement binding = json.get("binding"); + if (binding != null && binding.isJsonObject()) { + // the binding's shape is checked when the assertion is verified + view.binding = new Binding(); + view.binding.method = stringMember(binding.getAsJsonObject(), "method"); + view.binding.signature = stringMember(binding.getAsJsonObject(), "signature"); + } + return view; + } + + private static String stringMember(JsonObject json, String member) { + JsonElement element = json.get(member); + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString() + ? element.getAsString() + : null; } - var assertionAsJson = gson.toJson(this); - JsonCanonicalizer jc = new JsonCanonicalizer(assertionAsJson); - return Hex.encodeHexString(digest.digest(jc.getEncodedUTF8())); + /** A string, or a number or boolean as its JSON text, as the reflective reader gave them. */ + private static String textMember(JsonObject json, String member) { + JsonElement element = json.get(member); + return element != null && element.isJsonPrimitive() ? element.getAsString() : null; + } } // Sign the assertion with the given hash and signature using the key. @@ -1003,7 +1166,7 @@ public void sign(final HashValues hashValues, final AssertionConfig.AssertionKey // Store the binding and signature this.binding = new Binding(); - this.binding.method = AssertionConfig.BindingMethod.JWS.name(); + this.binding.method = AssertionConfig.BindingMethod.JWS.toString(); this.binding.signature = signedJWT.serialize(); } @@ -1017,7 +1180,6 @@ public Assertion.HashValues verify(AssertionConfig.AssertionKey assertionKey) } String signatureString = binding.signature; - binding = null; // Clear the binding after use SignedJWT signedJWT = SignedJWT.parse(signatureString); JWSHeader header = signedJWT.getHeader(); @@ -1135,6 +1297,49 @@ private JWSVerifier createVerifier(RSAPublicKey publicKey) { } } + private static final AssertionValueAdapter STATEMENT_ADAPTER = new AssertionValueAdapter(); + + /** + * Reads each assertion as a JSON tree and keeps that tree, so verification hashes the + * assertion as received rather than a re-serialization of the typed fields; writes the + * assertion's wire form. Attached to {@link Assertion} itself so that every {@link Gson} + * instance, including the plain one the TDF writer uses, serializes assertions this way. + */ + static final class AssertionTypeAdapter extends TypeAdapter { + AssertionTypeAdapter() { + // instantiated by Gson through @JsonAdapter + } + + @Override + public void write(JsonWriter out, Assertion assertion) throws IOException { + if (assertion == null) { + out.nullValue(); + return; + } + // a member that is JSON null is part of what was hashed, so it has to be written + boolean serializeNulls = out.getSerializeNulls(); + out.setSerializeNulls(true); + try { + AssertionJson.JSON_ELEMENT.write(out, assertion.toJsonTree()); + } finally { + out.setSerializeNulls(serializeNulls); + } + } + + @Override + public Assertion read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + JsonElement json = AssertionJson.JSON_ELEMENT.read(in); + if (!json.isJsonObject()) { + throw new JsonParseException("an assertion must be a JSON object, at " + in.getPreviousPath()); + } + return Assertion.fromJson(json.getAsJsonObject()); + } + } + public static class AssertionValueAdapter implements JsonDeserializer { @Override public AssertionConfig.Statement deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { @@ -1259,6 +1464,11 @@ private static void validateManifest(Manifest result) { if (result.assertions == null) { result.assertions = new ArrayList<>(); } + for (Assertion assertion : result.assertions) { + if (assertion == null) { + throw new IllegalArgumentException("Invalid null assertion in manifest"); + } + } requireManifestField(result.payload, "Manifest with null payload"); requireManifestField(result.encryptionInformation, "Manifest with null encryptionInformation"); 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 dd000a8d..51b6e977 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -654,7 +654,9 @@ private static Config.IntegrityAlgorithm rootIntegrityAlgorithmFromManifest(Stri /** * @throws IllegalArgumentException if {@code tdfConfig} selects an integrity algorithm - * that cannot be written. Unchecked and not an + * that cannot be written, or holds an assertion that a + * reader would reject while parsing (its wire form is not + * I-JSON). Unchecked and not an * {@link SDKException}, matching how the config layer * already reports out-of-range values (see * {@link Config#withSegmentSize}): this is a caller @@ -772,7 +774,8 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo assertion.statement = assertionConfig.statement; assertion.appliesToState = assertionConfig.appliesToState.toString(); - var assertionHashAsHex = assertion.hash(); + // the wire form written below is exactly what is hashed here, minus the binding + String assertionHashAsHex = assertion.hashForWriting(); byte[] assertionHash; if (tdfConfig.hexEncodeRootAndSegmentHashes) { assertionHash = assertionHashAsHex.getBytes(StandardCharsets.UTF_8); @@ -1002,10 +1005,12 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) } catch (ParseException | JOSEException | java.security.cert.CertificateException e) { throw new SDKException("error validating assertion hash", e); } - var hashOfAssertionAsHex = assertion.hash(); + // the assertion exactly as received, never a re-serialization of the typed fields + var hashOfAssertionAsHex = AssertionJson.hash(assertion.received()); if (!Objects.equals(hashOfAssertionAsHex, hashValues.getAssertionHash())) { - throw new SDK.AssertionException("assertion hash mismatch", assertion.id); + throw new SDK.AssertionFailureException(assertion.id, AssertionFailureReason.BINDING_MISMATCH, + "assertion hash mismatch"); } byte[] hashOfAssertion; @@ -1024,7 +1029,8 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) var encodeSignature = Base64.getEncoder().encodeToString(signature); if (!Objects.equals(encodeSignature, hashValues.getSignature())) { - throw new SDK.AssertionException("failed integrity check on assertion signature", assertion.id); + throw new SDK.AssertionFailureException(assertion.id, AssertionFailureReason.BINDING_MISMATCH, + "failed integrity check on assertion signature"); } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AssertionHashTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionHashTest.java new file mode 100644 index 00000000..59d1fb63 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionHashTest.java @@ -0,0 +1,278 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.commons.codec.binary.Hex; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link Manifest.Assertion#computeAssertionHash}: the hash input is the assertion as received, + * minus only its top-level {@code binding} (spec Section 4), after the JSON profile checks of + * Section 3. + */ +class AssertionHashTest { + + /** Single quotes stand in for double quotes, to keep the fixtures readable. */ + private static String json(String singleQuoted) { + return singleQuoted.replace('\'', '"'); + } + + private static String sha256Hex(String text) throws Exception { + return Hex.encodeHexString(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); + } + + /** The classification example of spec Section 4.4. */ + static JsonObject classificationAssertion() { + var statement = new JsonObject(); + statement.addProperty("format", "json+stanag5636"); + statement.addProperty("schema", "urn:nato:stanag:5636:A:1:elements:json"); + statement.addProperty("value", + "{\"ocl\":{\"pol\":\"62c76c68-d73d-4628-8ccc-4c1e18118c22\",\"cls\":\"SECRET\",\"dcr\":\"2024-10-21T20:47:36Z\"}}"); + var assertion = new JsonObject(); + assertion.addProperty("id", "classification"); + assertion.addProperty("type", "handling"); + assertion.addProperty("scope", "payload"); + assertion.addProperty("appliesToState", "unencrypted"); + assertion.add("statement", statement); + return assertion; + } + + /** The ACP 240-style example of spec Sections 2.2 and 4.4, with an object-valued label. */ + static JsonObject acp240Assertion() { + var catl = new JsonObject(); + catl.addProperty("type", "P"); + catl.addProperty("name", "Releasable To"); + var vals = new JsonArray(); + vals.add("USA"); + catl.add("vals", vals); + var catls = new JsonArray(); + catls.add(catl); + var ocl = new JsonObject(); + ocl.addProperty("pol", "62c76c68-d73d-4628-8ccc-4c1e18118c22"); + ocl.addProperty("cls", "CONFIDENTIAL"); + ocl.add("catl", catls); + ocl.addProperty("dcr", "2026-11-06T12:00:00Z"); + var context = new JsonObject(); + context.addProperty("@base", "urn:nato:stanag:5636:A:1:elements:json"); + var value = new JsonObject(); + value.add("@context", context); + value.add("ocl", ocl); + var statement = new JsonObject(); + statement.addProperty("format", "json-structured"); + statement.addProperty("schema", "urn:nato:stanag:5636:A:1:elements:json"); + statement.add("value", value); + var assertion = new JsonObject(); + assertion.addProperty("id", "3f1c2d9e-8a4b-4c6d-9e0f-1a2b3c4d5e6f"); + assertion.addProperty("type", "handling"); + assertion.addProperty("scope", "payload"); + assertion.addProperty("appliesToState", "encrypted"); + assertion.add("statement", statement); + return assertion; + } + + @Test + void matchesTheSpecWorkedExamples() { + assertThat(Manifest.Assertion.computeAssertionHash(classificationAssertion().toString())) + .isEqualTo("456d05d6a3f48a14aeee20c8d5f0429d07e5be3f1695fcec2dbc755cd9d64495"); + + var withExtension = classificationAssertion(); + withExtension.add("extensions", JsonParser.parseString(json("{'hash':'deadbeef','note':'coi'}"))); + assertThat(Manifest.Assertion.computeAssertionHash(withExtension.toString())) + .as("an unmodelled member is part of the hash") + .isEqualTo("a7347ff1c718c0ee7e88558de64389d5e7194990eb6c78662d0bcd9cfb19c7ba"); + + assertThat(Manifest.Assertion.computeAssertionHash(acp240Assertion().toString())) + .as("an object-valued statement is hashed as an object") + .isEqualTo("46853cb52912fd76f3ff90df3936158ee220b8e5973c5d02bb6e50f468596fba"); + } + + @Test + void ignoresOnlyTheTopLevelBindingAndTheLayout() { + var expected = Manifest.Assertion.computeAssertionHash(classificationAssertion().toString()); + + var withBinding = classificationAssertion(); + withBinding.add("binding", JsonParser.parseString(json("{'method':'jws','signature':'a.b.c'}"))); + assertThat(Manifest.Assertion.computeAssertionHash(withBinding.toString())).isEqualTo(expected); + + var reordered = new JsonObject(); + reordered.add("statement", classificationAssertion().get("statement")); + reordered.addProperty("type", "handling"); + reordered.addProperty("appliesToState", "unencrypted"); + reordered.addProperty("scope", "payload"); + reordered.addProperty("id", "classification"); + var spaced = new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(reordered); + assertThat(spaced).contains("\n"); + assertThat(Manifest.Assertion.computeAssertionHash(spaced)).isEqualTo(expected); + } + + @Test + void hashesNestedMembersNamedLikeExcludedOnes() { + var plain = classificationAssertion(); + var nested = classificationAssertion(); + nested.getAsJsonObject("statement").addProperty("hash", "x"); + nested.getAsJsonObject("statement").add("binding", JsonParser.parseString("{\"signingKey\":\"y\"}")); + assertThat(Manifest.Assertion.computeAssertionHash(nested.toString())) + .isNotEqualTo(Manifest.Assertion.computeAssertionHash(plain.toString())); + } + + @Test + void anExplicitNullIsHashed() { + var withNull = classificationAssertion(); + withNull.add("appliesToState", com.google.gson.JsonNull.INSTANCE); + var without = classificationAssertion(); + without.remove("appliesToState"); + assertThat(Manifest.Assertion.computeAssertionHash(withNull.toString())) + .isNotEqualTo(Manifest.Assertion.computeAssertionHash(without.toString())); + } + + static Stream canonicalForms() { + return Stream.of( + // numbers are IEEE 754 doubles in RFC 8785, printed the way ECMAScript prints them + Arguments.of("{'id':'a','n':1E21}", "{'id':'a','n':1e+21}"), + Arguments.of("{'id':'a','n':-0}", "{'id':'a','n':0}"), + Arguments.of("{'id':'a','n':9007199254740993}", "{'id':'a','n':9007199254740992}"), + Arguments.of("{'id':'a','n':1.0}", "{'id':'a','n':1}"), + Arguments.of("{'id':'a','n':0.0000001}", "{'id':'a','n':1e-7}"), + Arguments.of("{'id':'a','n':1e-400}", "{'id':'a','n':0}"), + Arguments.of("{'id':'a','n':123456789012345680000}", "{'id':'a','n':123456789012345680000}"), + // escapes are decoded, and only the RFC 8785 set is re-escaped + Arguments.of("{'id':'\\u0061','s':'\\u00e9\\/\\u2028'}", "{'id':'a','s':'é/
'}"), + Arguments.of("{'id':'a','s':'\\u001f\\b'}", "{'id':'a','s':'\\u001f\\b'}"), + // keys sort by UTF-16 code unit, not by code point + Arguments.of("{'fi':2,'😀':1,'id':'a'}", "{'id':'a','😀':1,'fi':2}")); + } + + @ParameterizedTest + @MethodSource("canonicalForms") + void canonicalizesUnderRfc8785(String input, String canonical) throws Exception { + assertThat(Manifest.Assertion.computeAssertionHash(json(input))).isEqualTo(sha256Hex(json(canonical))); + } + + static Stream rejected() { + return Stream.of( + Arguments.of("{'id':'a','id':'b'}", AssertionFailureReason.DUPLICATE_MEMBER, null), + Arguments.of("{'id':'a','\\u0069d':'b'}", AssertionFailureReason.DUPLICATE_MEMBER, null), + Arguments.of("{'id':'a','x':1,'x':1}", AssertionFailureReason.DUPLICATE_MEMBER, "a"), + Arguments.of("{'id':'a','statement':{'value':{'x':1,'x':2}}}", AssertionFailureReason.DUPLICATE_MEMBER, "a"), + Arguments.of("{'id':'a','ID':'b'}", AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + Arguments.of("{'Id':'b'}", AssertionFailureReason.AMBIGUOUS_MEMBER, null), + Arguments.of("{'id':'a','Statement':{}}", AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + Arguments.of("{'id':'a','APPLIESTOSTATE':'x'}", AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + Arguments.of("{'id':'a','ſtatement':{}}", AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + Arguments.of("{'id':'a','statement':{'Value':'x'}}", AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + Arguments.of("{'id':'a','binding':{'Method':'jws','signature':'x'}}", AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + Arguments.of("{'id':'a','binding':{'BDO':'x'}}", AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + Arguments.of("{'id':'a','x':'\\uDEAD'}", AssertionFailureReason.INVALID_JSON, "a"), + Arguments.of("{'id':'a','x':'\\uD83D'}", AssertionFailureReason.INVALID_JSON, "a"), + Arguments.of("{'id':'a','\\uDEAD':1}", AssertionFailureReason.INVALID_JSON, "a"), + Arguments.of("{'id':'a','x':'\\uFFFE'}", AssertionFailureReason.INVALID_JSON, "a"), + Arguments.of("{'id':'a','x':'\\uFDD0'}", AssertionFailureReason.INVALID_JSON, "a"), + Arguments.of("{'id':'a','x':'\\uD83F\\uDFFF'}", AssertionFailureReason.INVALID_JSON, "a"), + Arguments.of("{'id':'a','statement':{'value':{'n':[1e400]}}}", AssertionFailureReason.INVALID_JSON, "a"), + Arguments.of("{'id':'a','n':-1E400}", AssertionFailureReason.INVALID_JSON, "a"), + // a bad id is never reported as the assertion's id + Arguments.of("{'id':'\\uDEAD'}", AssertionFailureReason.INVALID_JSON, null), + // the first failure in document order wins + Arguments.of("{'id':'a','ID':1,'x':'\\uDEAD'}", AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + Arguments.of("{'x':'\\uDEAD','id':'a','id':'b'}", AssertionFailureReason.INVALID_JSON, null)); + } + + @ParameterizedTest + @MethodSource("rejected") + void rejectsWhatSection3Forbids(String input, AssertionFailureReason reason, String id) { + assertThatThrownBy(() -> Manifest.Assertion.computeAssertionHash(json(input))) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> { + assertThat(e.getReason()).isEqualTo(reason); + assertThat(e.getAssertionId()).isEqualTo(id); + assertThat(e).hasMessageContaining("reason=" + reason.code()); + if (id != null) { + assertThat(e).hasMessageContaining("id: " + id); + } + }) + .isInstanceOf(SDK.TamperException.class); + } + + @ParameterizedTest + @ValueSource(strings = { + // not a case variant under Go's EqualFold, so an ordinary unmodelled member + "{'id':'a','ıd':'x'}", + // the rule does not reach into statement values or unmodelled members + "{'id':'a','statement':{'value':{'ID':1,'Value':2,'Binding':3}}}", + "{'id':'a','extensions':{'Binding':1,'Statement':2}}", + // a valid surrogate pair and a noncharacter-free astral code point are fine + "{'id':'a','x':'\\uD83D\\uDE00'}", + // no id at all is still hashable; the id is checked when the assertion is verified + "{'type':'handling'}", + "{}" + }) + void acceptsWhatSection3Allows(String input) { + assertThat(Manifest.Assertion.computeAssertionHash(json(input))).matches("[0-9a-f]{64}"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "[]", "\"x\"", "{", "{} {}", "{'id':'a',}", "{id:'a'}", "/*c*/{}", "{'id':'a'} x"}) + void rejectsTextThatIsNotOneStrictJsonObject(String input) { + assertThatThrownBy(() -> Manifest.Assertion.computeAssertionHash(json(input))) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON)); + } + + @Test + void anUnmodelledMemberChangesTheHash() { + var original = classificationAssertion(); + original.addProperty("note", "approved"); + var tampered = classificationAssertion(); + tampered.addProperty("note", "rejected"); + assertThat(Manifest.Assertion.computeAssertionHash(original.toString())) + .isNotEqualTo(Manifest.Assertion.computeAssertionHash(tampered.toString())); + } + + private static String message(String id, String detail) { + return new SDK.AssertionFailureException(id, AssertionFailureReason.BINDING_MISMATCH, detail).getMessage(); + } + + @Test + void idsAreRenderedSafelyInMessages() { + var failure = new SDK.AssertionFailureException("a\nreason=verified", AssertionFailureReason.BINDING_MISMATCH, null); + // the typed field keeps the exact id + assertThat(failure.getAssertionId()).isEqualTo("a\nreason=verified"); + assertThat(failure.getMessage()) + .doesNotContain("\n") + .contains("id: a\\nreason=verified") + .endsWith(", reason=binding-mismatch"); + + // C1 controls such as NEL, DEL, and other C0 controls are escaped JSON-style + assertThat(message("a\u0085b\u007fc\u001bd\te", null)) + .contains("id: a\\u0085b\\u007fc\\u001bd\\te,"); + // a backslash is escaped, so escape-like text in an id cannot pass for an escape + assertThat(message("a\\u000ab", null)).contains("id: a\\\\u000ab,"); + // text that looks like a reason stays part of the id, before the real reason + assertThat(message("x, reason=signature-invalid", null)) + .endsWith("id: x, reason=signature-invalid, reason=binding-mismatch"); + } + + @Test + void longIdsAndDetailsAreCut() { + String id = "\uD83D\uDE00".repeat(100_000); + var failure = new SDK.AssertionFailureException(id, AssertionFailureReason.BINDING_MISMATCH, "x".repeat(1_000_000)); + assertThat(failure.getAssertionId()).isEqualTo(id); + String message = failure.getMessage(); + assertThat(message).contains("id: " + "\uD83D\uDE00".repeat(256) + "\u2026, reason=binding-mismatch: ") + .endsWith("x".repeat(512) + "\u2026") + .hasSizeLessThan(2_000); + // an id of exactly the limit is not cut + assertThat(message("y".repeat(256), null)).contains("id: " + "y".repeat(256) + ", reason="); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java index e67672d0..9e8f410d 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java @@ -1,5 +1,7 @@ package io.opentdf.platform.sdk; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.apache.commons.compress.utils.SeekableInMemoryByteChannel; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -91,7 +93,9 @@ void rejects(String manifest, AssertionFailureReason reason, String id) { // the I-JSON checks are required only inside assertions ",'extra':{'n':1e400,'s':'\\uDEAD'}", // case variants are only checked at the four modelled levels - ",'payload2':{'Assertions':1},'assertions':[{'id':'a','statement':{'value':{'ID':1}},'extensions':{'Binding':1}}]" + ",'payload2':{'Assertions':1},'assertions':[{'id':'a','statement':{'value':{'ID':1}},'extensions':{'Binding':1}}]", + // values the typed model coerces or ignores still parse + ",'assertions':[{'id':5,'type':{},'binding':'not an object'}]" }) void accepts(String rootExtra) { var manifest = manifest(rootExtra); @@ -132,6 +136,72 @@ static byte[] tdfWithManifest(byte[] manifest) throws IOException { return out.toByteArray(); } + @Test + void keepsEachAssertionAsReceived() { + var assertion = "{'id':'a','type':'handling','scope':'payload','appliesToState':null," + + "'statement':{'format':'json-structured','schema':'s','value':{'n':1.50,'ocl':{'cls':'SECRET'}},'extra':[1]}," + + "'extensions':{'note':'coi'},'binding':{'method':'JWS','signature':'a.b.c'}}"; + var manifest = Manifest.readManifest(withAssertions("[" + assertion + "]")); + var read = manifest.assertions.get(0); + assertThat(read.id).isEqualTo("a"); + assertThat(read.statement.value).isEqualTo("{\"n\":1.50,\"ocl\":{\"cls\":\"SECRET\"}}"); + assertThat(read.binding.method).isEqualTo("JWS"); + + String expectedHash = Manifest.Assertion.computeAssertionHash(json(assertion)); + assertThat(AssertionJson.hash(read.received())).isEqualTo(expectedHash); + + // serializing writes the assertion back as received, unmodelled members and nulls included + var written = JsonParser.parseString(Manifest.toJson(manifest)).getAsJsonObject() + .getAsJsonArray("assertions").get(0); + assertThat(written).isEqualTo(JsonParser.parseString(json(assertion))); + assertThat(Manifest.Assertion.computeAssertionHash(written.toString())).isEqualTo(expectedHash); + + // and so does the plain Gson the TDF writer uses + var plain = JsonParser.parseString(new com.google.gson.Gson().toJson(manifest)).getAsJsonObject() + .getAsJsonArray("assertions").get(0); + assertThat(plain).isEqualTo(JsonParser.parseString(json(assertion))); + } + + @Test + void aChangedTypedFieldReplacesOnlyItsMember() throws IOException { + var assertion = "{'id':'a','statement':{'format':'f','value':{'x':1},'extra':true},'extensions':{'note':'coi'}}"; + var manifest = Manifest.readManifest(withAssertions("[" + assertion + "]")); + var read = manifest.assertions.get(0); + read.statement.format = "g"; + read.scope = "tdo"; + + JsonObject written = JsonParser.parseString(Manifest.toJson(manifest)).getAsJsonObject() + .getAsJsonArray("assertions").get(0).getAsJsonObject(); + assertThat(written).isEqualTo(JsonParser.parseString( + json("{'id':'a','statement':{'format':'g','value':{'x':1},'extra':true},'extensions':{'note':'coi'},'scope':'tdo'}"))); + assertThat(read.hash()).isEqualTo(Manifest.Assertion.computeAssertionHash(written.toString())); + // the received object itself is never modified + assertThat(AssertionJson.hash(read.received())).isEqualTo(Manifest.Assertion.computeAssertionHash(json(assertion))); + } + + @Test + void anAssertionBuiltInCodeSerializesItsNonNullFields() throws IOException { + var assertion = new Manifest.Assertion(); + assertion.id = "a"; + assertion.type = "handling"; + assertion.statement = new AssertionConfig.Statement(); + assertion.statement.value = "{\"x\":1}"; + assertion.binding = new Manifest.Binding(); + assertion.binding.method = "jws"; + assertion.binding.signature = "a.b.c"; + + var written = JsonParser.parseString(new com.google.gson.Gson().toJson(assertion)); + assertThat(written).isEqualTo(JsonParser.parseString( + json("{'id':'a','type':'handling','statement':{'value':'{\\'x\\':1}'},'binding':{'method':'jws','signature':'a.b.c'}}"))); + assertThat(assertion.hash()).isEqualTo(Manifest.Assertion.computeAssertionHash(written.toString())); + } + + @Test + void nullAssertionsAreMalformed() { + assertThatThrownBy(() -> Manifest.readManifest(withAssertions("[null]"))) + .isInstanceOf(IllegalArgumentException.class); + } + @Test void rejectsAByteOrderMark() throws IOException { var withBom = ("\uFEFF" + manifest("")).getBytes(StandardCharsets.UTF_8); @@ -141,6 +211,9 @@ void rejectsAByteOrderMark() throws IOException { assertThatThrownBy(() -> Manifest.checkManifestJson("\uFEFF" + manifest(""))) .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON)); + assertThatThrownBy(() -> Manifest.Assertion.computeAssertionHash("\uFEFF{\"id\":\"a\"}")) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON)); // a byte order mark is ordinary content anywhere else assertThatCode(() -> Manifest.readManifest(manifest(",'extra':'\uFEFF'"))).doesNotThrowAnyException(); } 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 6057cd94..b1ce4e24 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -642,6 +642,74 @@ void testSimpleTDFWithAssertionWithHS256() throws Exception { } } + /** + * The writer hashes the wire form it then writes, emits {@code "method": "jws"}, and keeps + * statement values as strings. + */ + @Test + void writerEmitsJwsBindingsOverTheWrittenAssertion() throws Exception { + var assertionConfig = new AssertionConfig(); + assertionConfig.id = "assertion1"; + assertionConfig.type = AssertionConfig.Type.HandlingAssertion; + assertionConfig.scope = AssertionConfig.Scope.Payload; + assertionConfig.appliesToState = AssertionConfig.AppliesToState.Encrypted; + assertionConfig.statement = new AssertionConfig.Statement(); + assertionConfig.statement.format = "json"; + assertionConfig.statement.schema = "urn:nato:stanag:5636:A:1:elements:json"; + assertionConfig.statement.value = "{\"ocl\":{\"cls\":\"SECRET\"},\"n\":1e21}"; + + var rsaKasInfo = new Config.KASInfo(); + rsaKasInfo.URL = "https://example.com/kas" + 0; + var tdfOutputStream = new ByteArrayOutputStream(); + new TDF(new FakeServicesBuilder().setKas(kas).setKeyAccessServerRegistryService(kasRegistryService).build()) + .createTDF(new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8)), tdfOutputStream, + Config.newTDFConfig(Config.withAutoconfigure(false), Config.withKasInformation(rsaKasInfo), + Config.withSystemMetadataAssertion(), Config.withAssertionConfig(assertionConfig))); + + var manifestJson = new StringWriter(); + try (var manifestReader = TDFReader.open(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())).manifest()) { + manifestReader.transferTo(manifestJson); + } + var assertions = JsonParser.parseString(manifestJson.toString()).getAsJsonObject().getAsJsonArray("assertions"); + assertThat(assertions).hasSize(2); + for (var written : assertions) { + var binding = written.getAsJsonObject().getAsJsonObject("binding"); + assertThat(binding.get("method").getAsString()).isEqualTo("jws"); + assertThat(written.getAsJsonObject().getAsJsonObject("statement").get("value").getAsJsonPrimitive().isString()) + .isTrue(); + var claims = com.nimbusds.jwt.SignedJWT.parse(binding.get("signature").getAsString()).getJWTClaimsSet(); + assertThat(claims.getStringClaim("assertionHash")) + .isEqualTo(Manifest.Assertion.computeAssertionHash(written.toString())); + } + } + + /** The writer refuses an assertion a reader would reject while parsing, as a configuration error. */ + @Test + void writerRefusesAnAssertionThatIsNotIJson() { + var rsaKasInfo = new Config.KASInfo(); + rsaKasInfo.URL = "https://example.com/kas" + 0; + for (var bad : List.of("value", "id")) { + var assertionConfig = new AssertionConfig(); + assertionConfig.id = "id".equals(bad) ? "assertion\uD800" : "assertion1"; + assertionConfig.type = AssertionConfig.Type.BaseAssertion; + assertionConfig.scope = AssertionConfig.Scope.Payload; + assertionConfig.appliesToState = AssertionConfig.AppliesToState.Unencrypted; + assertionConfig.statement = new AssertionConfig.Statement(); + assertionConfig.statement.format = "string"; + assertionConfig.statement.value = "value".equals(bad) ? "lone \uDC00 surrogate" : "fine"; + var config = Config.newTDFConfig(Config.withAutoconfigure(false), Config.withKasInformation(rsaKasInfo), + Config.withAssertionConfig(assertionConfig)); + var tdf = new TDF(new FakeServicesBuilder().setKas(kas).setKeyAccessServerRegistryService(kasRegistryService).build()); + assertThatThrownBy(() -> tdf.createTDF(new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8)), + new ByteArrayOutputStream(), config)) + .as(bad) + .isInstanceOf(IllegalArgumentException.class) + .isNotInstanceOf(SDK.TamperException.class) + .hasNoCause() + .hasMessageContaining("reason=invalid-json"); + } + } + @Test void testSimpleTDFWithAssertionWithHS256Failure() throws Exception { // var keypair = CryptoUtils.generateRSAKeypair();