diff --git a/.github/workflows/fuzz.yaml b/.github/workflows/fuzz.yaml index fa9de983..8d8b1d0e 100644 --- a/.github/workflows/fuzz.yaml +++ b/.github/workflows/fuzz.yaml @@ -33,6 +33,7 @@ jobs: target: - fuzzZipRead - fuzzTDF + - fuzzIsValidTdf steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index b073b955..74b34003 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -61,7 +61,7 @@ class Versions { } @CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class, - Command.Supports.class }, version = "{\"version\":\"" + Versions.SDK + Command.Supports.class, Command.Validate.class }, version = "{\"version\":\"" + Versions.SDK + "\",\"tdfSpecVersion\":\"" + Versions.TDF_SPEC + "\"}") class Command { @Option(names = { "-V", "--version" }, versionHelp = true, description = "display version info") @@ -77,7 +77,7 @@ class Command { static class Supports implements Callable { // Static, always-on capabilities of this build. There is no "known but // unsupported" feature here: anything not in this list is simply unrecognized. - private static final List FEATURES = List.of("dpop", "dpop_nonce_challenge"); + private static final List FEATURES = List.of("dpop", "dpop_nonce_challenge", "tdf_validity"); @CommandLine.Parameters(index = "0", arity = "0..1", description = "Feature to check (e.g., dpop). Omit to list all supported features.") private String feature; @@ -114,6 +114,45 @@ private void printFeatures(List features) { } } + /** + * {@code tdf validate }: {@link SDK#validateTdf} from the command line. The output and + * exit codes are shared with the Go and JavaScript CLIs, for the cross-SDK validity suite in + * opentdf/tests. + */ + @CommandLine.Command(name = "validate", + description = { "Check whether a file is a valid TDF. Needs no platform connection or credentials.", + "Prints one line of JSON: {\"valid\":true}, or {\"valid\":false,\"reason\":\"...\"}." }, + exitCodeListHeading = "Exit codes:%n", + exitCodeList = { "0:The file is a valid TDF.", "3:The file is not a valid TDF.", + "1:The check could not run, for example because the file could not be read.", + "2:The command line was not valid." }) + static class Validate implements Callable { + /** The exit code for a file that is not a valid TDF; picocli already uses 1 and 2 for errors. */ + static final int INVALID = 3; + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + @CommandLine.Parameters(index = "0", paramLabel = "", description = "The file to check") + private Path file; + + @Override + public Integer call() throws IOException { + Map result = new LinkedHashMap<>(); + int exitCode; + try (var tdf = FileChannel.open(file, StandardOpenOption.READ)) { + SDK.validateTdf(tdf); + result.put("valid", true); + exitCode = CommandLine.ExitCode.OK; + } catch (SDK.MalformedTDFException e) { + result.put("valid", false); + result.put("reason", e.getMessage()); + exitCode = INVALID; + } + System.out.println(GSON.toJson(result)); + return exitCode; + } + } + private static class AssertionKeyDeserializer implements JsonDeserializer { @Override public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT, diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java index 8995271d..dbc986c7 100644 --- a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -1,14 +1,21 @@ package io.opentdf.platform; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.opentdf.platform.sdk.ZipWriter; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import picocli.CommandLine; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.PrintStream; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -95,7 +102,7 @@ void supports_noArgs_listsFeatures_exits0() { String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports")); assertThat(code[0]).isEqualTo(0); - assertThat(out.lines()).containsExactlyInAnyOrder("dpop", "dpop_nonce_challenge"); + assertThat(out.lines()).containsExactlyInAnyOrder("dpop", "dpop_nonce_challenge", "tdf_validity"); } @Test @@ -104,7 +111,7 @@ void supports_noArgs_json_listsFeatures() { String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports", "--json")); assertThat(code[0]).isEqualTo(0); - assertThat(out.trim()).isEqualTo("{\"dpop\":true,\"dpop_nonce_challenge\":true}"); + assertThat(out.trim()).isEqualTo("{\"dpop\":true,\"dpop_nonce_challenge\":true,\"tdf_validity\":true}"); } @Test @@ -137,4 +144,117 @@ void supports_unknownFeature_json_false() { assertThat(out.trim()).isEqualTo("{\"unknown_feature\":false}"); } + @Test + void supports_tdf_validity_exits_0() { + int code = new CommandLine(new Command()).execute("supports", "tdf_validity"); + assertThat(code).isEqualTo(0); + } + + private static final String MINIMAL_MANIFEST = "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\"," + + "\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{}}"; + + @TempDir + Path tempDir; + + private Path tdfWithManifest(String manifest) throws IOException { + var out = new ByteArrayOutputStream(); + var zip = new ZipWriter(out); + zip.data("0.payload", new byte[16]); + zip.data("0.manifest.json", manifest.getBytes(StandardCharsets.UTF_8)); + zip.finish(); + return Files.write(Files.createTempFile(tempDir, "validate", ".tdf"), out.toByteArray()); + } + + /** Runs {@code validate} the way the jar does, without platform options, and returns stdout. */ + private String validate(int[] code, String... args) { + String[] command = new String[args.length + 1]; + command[0] = "validate"; + System.arraycopy(args, 0, command, 1, args.length); + return captureStdout(() -> code[0] = new CommandLine(new Command()).execute(command)); + } + + @Test + void validate_validTdf_printsValidAndExits0() throws IOException { + int[] code = new int[1]; + String out = validate(code, tdfWithManifest(MINIMAL_MANIFEST).toString()); + + assertThat(code[0]).isEqualTo(0); + assertThat(out).isEqualTo("{\"valid\":true}" + System.lineSeparator()); + } + + @Test + void validate_invalidManifest_printsReasonAndExits3() throws IOException { + int[] code = new int[1]; + String out = validate(code, tdfWithManifest("{\"encryptionInformation\":{}}").toString()); + + assertThat(code[0]).isEqualTo(3); + assertThat(out.lines()).hasSize(1); + JsonObject result = JsonParser.parseString(out).getAsJsonObject(); + assertThat(result.keySet()).containsExactly("valid", "reason"); + assertThat(result.get("valid").getAsBoolean()).isFalse(); + assertThat(result.get("reason").getAsString()).contains("lax manifest schema"); + } + + @Test + void validate_fileThatIsNotAZip_exits3() throws IOException { + int[] code = new int[1]; + Path notAZip = Files.writeString(tempDir.resolve("not-a-zip"), MINIMAL_MANIFEST); + String out = validate(code, notAZip.toString()); + + assertThat(code[0]).isEqualTo(3); + assertThat(JsonParser.parseString(out).getAsJsonObject().get("valid").getAsBoolean()).isFalse(); + } + + @Test + void validate_emptyFile_exits3() throws IOException { + int[] code = new int[1]; + String out = validate(code, Files.createFile(tempDir.resolve("empty")).toString()); + + assertThat(code[0]).isEqualTo(3); + assertThat(JsonParser.parseString(out).getAsJsonObject().get("valid").getAsBoolean()).isFalse(); + } + + /** A reason with quotes, angle brackets and the like is still one line of JSON. */ + @Test + void validate_reasonIsEscapedJson() throws IOException { + int[] code = new int[1]; + String out = validate(code, tdfWithManifest("{\"<'quoted'>\": \"\\u0022\"").toString()); + + assertThat(code[0]).isEqualTo(3); + assertThat(out.lines()).hasSize(1); + assertThat(JsonParser.parseString(out).getAsJsonObject().get("reason").getAsString()) + .startsWith("manifest is not valid JSON"); + } + + /** A file that can't be read is not an invalid TDF: the check didn't run. */ + @Test + void validate_missingFile_exitsWithAnotherCodeAndPrintsNothing() { + int[] code = new int[1]; + StringWriter err = new StringWriter(); + String out = captureStdout(() -> { + CommandLine cli = new CommandLine(new Command()); + cli.setErr(new PrintWriter(err)); + code[0] = cli.execute("validate", tempDir.resolve("does-not-exist.tdf").toString()); + }); + + assertThat(code[0]).isNotIn(0, 3); + assertThat(code[0]).isEqualTo(CommandLine.ExitCode.SOFTWARE); + assertThat(out).isEmpty(); + } + + @Test + void validate_withoutAFile_isAUsageError() { + int[] code = new int[1]; + StringWriter err = new StringWriter(); + String out = captureStdout(() -> { + CommandLine cli = new CommandLine(new Command()); + cli.setErr(new PrintWriter(err)); + code[0] = cli.execute("validate"); + }); + + assertThat(code[0]).isEqualTo(CommandLine.ExitCode.USAGE); + assertThat(out).isEmpty(); + assertThat(err.toString()).contains("Missing required parameter: ''"); + } + } diff --git a/sdk/pom.xml b/sdk/pom.xml index b1fbf506..4be37151 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -55,6 +55,13 @@ java-json-canonicalization 1.1 + + + dev.harrel + json-schema + 1.9.1 + build.buf protovalidate 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 633f1040..1df8b52c 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -166,6 +166,12 @@ public Optional getSrtSigner() { * An archive that lists one name twice is rejected, again matching {@link TDFReader}: * passing it here and then failing the read would hand callers an exception from the * very check this method exists to spare them. + *

+ * This looks only at the entry names in the central directory. It never reads the manifest, + * so an archive whose manifest is missing fields, or is not JSON at all, still passes. To + * check the manifest too, use {@link #isValidTdf}, which costs a read and parse of the + * manifest. Unlike that method, this one never throws: a channel that can't be read is + * reported as not a TDF. * * @param channel A channel containing the bytes of the potential Z-TDF * @return `true` if @@ -192,6 +198,60 @@ public static boolean isTDF(SeekableByteChannel channel) { && names.contains(TDFWriter.TDF_PAYLOAD_FILE_NAME); } + /** + * Checks whether the channel holds a valid TDF: an archive that {@link #readManifest} and + * {@link #loadTDF} can open, whose manifest is JSON that satisfies the lax manifest schema. + * This asks the same question as the Go SDK's {@code IsValidTdf} and the web SDK's + * {@code isValidTdf}, against the same schema. No platform connection or credentials are + * needed. + *

+ * A TDF is valid if all of these hold: + *

    + *
  • It is a zip archive that lists no entry name twice and has a {@code 0.payload} + * entry.
  • + *
  • It has a manifest entry. The spec name {@code manifest.json} is used if present, and + * otherwise {@code 0.manifest.json}, the name this SDK writes. When {@code manifest.json} + * is present but invalid, {@code 0.manifest.json} is not tried.
  • + *
  • The manifest is exactly one RFC 8259 JSON value, in UTF-8, with no byte order mark + * and nothing after the value, nested at most 10,000 deep.
  • + *
  • The manifest satisfies {@code manifest-lax.schema.json} from the Go SDK v0.35.0.
  • + *
+ * Nothing is decrypted or verified: not the payload, the integrity information, the policy + * binding or the assertion signatures. The lax schema leaves out fields that decryption + * needs, so {@code true} does not promise that {@link #loadTDF} succeeds. + *

+ * The manifest is parsed into memory and checked there. A typical manifest takes about 20 times + * its size in the file (about 2 KB per segment), and one built to inflate can take over 100 + * times its size. Callers that check untrusted files should limit how large a file they accept. + * + * @param channel the bytes to check. They are read from wherever the archive structure + * points, and the channel's position is not restored + * @return {@code true} if the channel holds a valid TDF, {@code false} if the archive or its + * manifest is malformed + * @throws IOException if the channel can't be read + * @see #validateTdf(SeekableByteChannel) validateTdf, to find out why a TDF is not valid + */ + public static boolean isValidTdf(SeekableByteChannel channel) throws IOException { + try { + validateTdf(channel); + return true; + } catch (MalformedTDFException e) { + return false; + } + } + + /** + * Makes the same check as {@link #isValidTdf}, and reports why a TDF is not valid. + * + * @param channel the bytes to check + * @throws MalformedTDFException if the channel does not hold a valid TDF. The message names + * the first problem found, on a single line. + * @throws IOException if the channel can't be read + */ + public static void validateTdf(SeekableByteChannel channel) throws IOException { + TDFValidator.validate(channel); + } + /** * Reads the {@link Manifest} without decrypting the TDF * @param tdfBytes A SeekableByteChannel containing the TDF data diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDFValidator.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDFValidator.java new file mode 100644 index 00000000..ad3a4ccf --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDFValidator.java @@ -0,0 +1,203 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.Strictness; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.MalformedJsonException; +import dev.harrel.jsonschema.Error; +import dev.harrel.jsonschema.Validator; +import dev.harrel.jsonschema.ValidatorFactory; +import dev.harrel.jsonschema.providers.GsonNode; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.PushbackReader; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The check behind {@link SDK#isValidTdf} and {@link SDK#validateTdf}. It asks the question that + * the Go SDK's {@code IsValidTdf} and the web SDK's {@code isValidTdf} ask: can the archive be + * opened, and does its manifest satisfy the lax manifest schema? + *

+ * {@code manifest-lax.schema.json} is vendored byte for byte from the OpenTDF Go SDK v0.35.0, + * + * sdk/schema/manifest-lax.schema.json (SHA-256 + * {@code 5e361720f005b23211a37d9e89c2c7ef51bc1b7df3353b82c2a568c3ed33f87d}). To refresh it, copy + * the file from a newer {@code sdk/} tag of opentdf/platform without editing it, and update the + * version and hash here and in {@code TDFValidatorTest}. + */ +final class TDFValidator { + + static final String LAX_SCHEMA_RESOURCE = "manifest-lax.schema.json"; + + /** + * The deepest nesting a manifest may have. This is the limit in Go's {@code encoding/json}, so + * Go and Java reject the same deeply nested manifests. + */ + static final int MAX_NESTING_DEPTH = 10_000; + + private static final char BYTE_ORDER_MARK = '\uFEFF'; + + /** Where Gson's syntax error messages say the problem is. */ + private static final Pattern GSON_LOCATION = Pattern.compile("at line \\d+ column \\d+"); + + private static final TypeAdapter JSON_TREE = new Gson().getAdapter(JsonElement.class); + + // registerSchema is the only call that changes a Validator, and it happens once, here. + // validate() gives each call its own evaluation state, so concurrent calls are safe + private static final Validator VALIDATOR = new ValidatorFactory() + .withJsonNodeFactory(new GsonNode.Factory()) + .createValidator(); + private static final URI LAX_SCHEMA = VALIDATOR.registerSchema(readLaxSchema()); + + private TDFValidator() { + } + + /** + * Returns normally if the channel holds a valid TDF. + * + * @throws SDK.MalformedTDFException if it doesn't, with the first problem found as its message + * @throws IOException if the channel can't be read + */ + static void validate(SeekableByteChannel channel) throws IOException { + // the same container checks as SDK.readManifest and loadTDF, so a TDF this calls valid is + // one their reader can open: the manifest is chosen the same way, and an archive listing + // a name twice, or without a payload, is refused here as it is there + var tdf = TDFReader.open(channel); + checkAgainstLaxSchema(readManifest(tdf)); + } + + private static JsonElement readManifest(TDFReader tdf) throws IOException { + try (var manifest = new PushbackReader(openManifest(tdf), 1)) { + // Gson skips a leading byte order mark without complaint, so look for one first. Go's + // JSON reader treats one as an error, and RFC 8259 forbids sending one + int first = manifest.read(); + if (first == BYTE_ORDER_MARK) { + throw new SDK.MalformedTDFException("manifest starts with a byte order mark"); + } + if (first != -1) { + manifest.unread(first); + } + return parseStrictly(manifest); + } catch (InvalidZipException e) { + // the entry stream found the data malformed part way through: it ran past the end of + // the archive, or, for a reader that inflates compressed entries, it is corrupt + throw malformed("manifest entry can't be read", e); + } + } + + /** {@link TDFReader#manifest()} reports an I/O failure as an SDKException; undo that. */ + private static Reader openManifest(TDFReader tdf) throws IOException { + try { + return tdf.manifest(); + } catch (SDK.MalformedTDFException e) { + throw e; + } catch (SDKException e) { + if (e.getCause() instanceof IOException) { + throw (IOException) e.getCause(); + } + throw e; + } + } + + /** + * Parses exactly one RFC 8259 JSON value. Gson's strict mode turns away what its lenient + * default lets through (comments, single quotes, unquoted names, trailing commas, NaN, and + * so on), and a second value or other data after the first is refused rather than ignored. + */ + private static JsonElement parseStrictly(Reader manifest) throws IOException { + var json = new JsonReader(manifest); + json.setStrictness(Strictness.STRICT); + json.setNestingLimit(MAX_NESTING_DEPTH); + JsonElement tree; + try { + // the type adapter rather than JsonParser.parseReader, which would turn an + // OutOfMemoryError into a JsonParseException, and so into a false negative + tree = JSON_TREE.read(json); + } catch (MalformedJsonException | EOFException e) { + // an EOFException is the JSON ending early; the archive ending early is an + // InvalidZipException, and any other IOException is a failure to read + throw new SDK.MalformedTDFException("manifest is not valid JSON: " + jsonProblem(e), e); + } + try { + // in strict mode, peek either finds the end or throws + if (json.peek() != JsonToken.END_DOCUMENT) { + throw new MalformedJsonException("expected the end of the manifest " + json); + } + } catch (MalformedJsonException e) { + var where = GSON_LOCATION.matcher(String.valueOf(e.getMessage())); + throw new SDK.MalformedTDFException("manifest has data after its JSON value" + + (where.find() ? ", starting " + where.group() : ""), e); + } + return tree; + } + + /** + * Gson's description of a syntax error, without the troubleshooting link it puts on a second + * line, and without its advice to parse leniently, which does not belong in a reason a TDF + * is invalid. + */ + private static String jsonProblem(IOException e) { + return String.valueOf(e.getMessage()).split("\\R", 2)[0] + .replace("Use JsonReader.setStrictness(Strictness.LENIENT) to accept malformed JSON", "malformed JSON"); + } + + private static void checkAgainstLaxSchema(JsonElement manifest) { + Validator.Result result; + try { + result = VALIDATOR.validate(LAX_SCHEMA, manifest); + } catch (RuntimeException e) { + // the manifest is already in memory, so nothing here reads from the channel: whatever + // goes wrong is down to the manifest. Gson refuses to convert a number of more than + // 10,000 digits, or one with an exponent that large, so a manifest holding one where + // the schema looks lands here; Go also reports a validator failure as invalid + throw malformed("manifest could not be checked against the lax manifest schema", e); + } + if (!result.isValid()) { + throw new SDK.MalformedTDFException(describe(result.getErrors())); + } + } + + private static String describe(List errors) { + var description = new StringBuilder("manifest does not match the lax manifest schema"); + if (!errors.isEmpty()) { + var first = errors.get(0); + String location = first.getInstanceLocation(); + description.append(" at ").append(location.isEmpty() ? "the top level" : location) + .append(": ").append(first.getError()); + if (errors.size() > 1) { + description.append(" (and ").append(errors.size() - 1).append(" more)"); + } + } + return oneLine(description.toString()); + } + + private static SDK.MalformedTDFException malformed(String what, Exception e) { + return new SDK.MalformedTDFException(oneLine(what + ": " + e.getMessage()), e); + } + + /** A reason is printed as one line of output, so it should be one line. */ + private static String oneLine(String message) { + return message.replaceAll("\\s*\\R\\s*", " "); + } + + private static String readLaxSchema() { + try (InputStream schema = Objects.requireNonNull(TDFValidator.class.getResourceAsStream(LAX_SCHEMA_RESOURCE), + LAX_SCHEMA_RESOURCE + " is missing from the classpath")) { + return new String(schema.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("reading " + LAX_SCHEMA_RESOURCE, e); + } + } +} diff --git a/sdk/src/main/resources/io/opentdf/platform/sdk/manifest-lax.schema.json b/sdk/src/main/resources/io/opentdf/platform/sdk/manifest-lax.schema.json new file mode 100644 index 00000000..9353fc3c --- /dev/null +++ b/sdk/src/main/resources/io/opentdf/platform/sdk/manifest-lax.schema.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/manifest.schema.json", + "title": "manifest", + "description": "TDF manifest in JSON", + "type": "object", + "properties": { + "payload": { + "type": "object", + "description": "An object which contains information describing the payload.", + "properties": { + "type": { + "description": "Describes the type of payload is associated with the TDF.", + "type": "string" + }, + "url": { + "description": "URL which points to payload. For reference types, with the default ZIP protocol, the URL would point to a local file within the zip.", + "type": "string" + }, + "protocol": { + "description": "The payload protocol. Default is zip." + }, + "isEncrypted": { + "description": "Designates whether or not the payload is encrypted, or cleartext.", + "type": "boolean" + }, + "mimeType": { + "description": "Specifies the type of file that is encrypted. Default is `application/octet-stream`.", + "type": "string" + }, + "tdf_spec_version": { + "description": "Semver version number of the TDF spec.", + "type": ["string", "null"] + } + }, + "required": ["type", "url", "protocol", "isEncrypted"] + }, + "encryptionInformation": { + "type": "object", + "properties": { + "type": { + "description": "Designates the type of key access protocol was used. Default, is split.", + "type": "string" + }, + "keyAccess": { + "description": "An array of keyAccess objects which are used to retrieve keys from one, or more Key Access Services", + "type": "array", + "items": { + "description": "A key access object", + "type": "object", + "properties": { + "type": { + "description": "The type of key access object.", + "type": "string", + "enum": ["wrapped", "ec-wrapped", "hybrid-wrapped", "remote"] + }, + "url": { + "description": "A fully qualified URL pointing to a key access service responsible for managing access to the encryption keys.", + "type": "string" + }, + "protocol": { + "description": "The protocol to be used for managing key access.", + "type": "string", + "enum": ["kas"] + }, + "wrappedKey": { + "description": "The symmetric key used to encrypt the payload. It has been encrypted using the public key of the KAS, then base64 encoded.", + "type": "string" + }, + "sid": { + "description": "A unique identifier for a single key split. In some complex policies, multiple key access objects may exist that share a specific key split. Using a splitId allows software to more efficiently operate by not reusing key material unnecessarily. ", + "type": ["string", "null"] + }, + "kid": { + "description": "A UUID for the specific keypair used for wrapping the symmetric key.", + "type": "string" + }, + "policyBinding": { + "description": "Object describing the policyBinding. Contains a hash, and an algorithm used. May also be a string, with just the hash. In that case default to HS256.", + "oneOf": [ + { + "type": "string" + },{ + "type": "object", + "properties": { + "alg": { + "description": "The policy binding algorithm used to generate the hash.", + "type": "string" + }, + "hash": { + "description": "This contains a keyed hash that will provide cryptographic integrity on the policy object, such that it cannot be modified or copied to another TDF, without invalidating the binding. Specifically, you would have to have access to the key in order to overwrite the policy.", + "type": "string" + } + } + ,"required": ["alg", "hash"] + } + ] + }, + "encryptedMetadata": { + "description": "Metadata associated with the TDF, and the request. The contents of the metadata are freeform, and are used to pass information from the client, and any plugins that may be in use by the KAS. The metadata stored here should not be used for primary access decisions. Base64.", + "type": ["string", "null"] + }, + "ephemeralPublicKey": { + "description": "For ECC wrapped keys, the client public key portion used, with the KAS public key identified with the key id, to derive a shared key that encrypts the wrapped key.", + "type": ["string", "null"] + } + } + }, + "required": ["type", "url", "protocol", "wrappedKey","sid", "kid", "policyBinding"] + }, + "method": { + "type": "object", + "properties": { + "algorithm": { + "description": "Algorithm used to encrypt the payload", + "type": "string" + }, + "isStreamable": { + "description": "Designates whether or not the payload is streamable.", + "type": "boolean" + } + }, + "required": ["algorithm", "isStreamable"] + }, + "integrityInformation": { + "type": "object", + "properties": { + "rootSignature": { + "type": "object", + "properties": { + "alg": { + "description": "Algorithm used to generate the root signature of the payload. Deliberately unconstrained here, unlike the strict schema: Lax backs IsValidTdf, which answers 'is this a TDF at all', and the root algorithm is enforced by validateRootSignature at every validation intensity. Rejecting it here would report ErrInvalidPerSchema instead of ErrRootSignatureFailure and would let a schema setting mask the integrity check.", + "type": "string" + }, + "sig": { + "description": "The payload signature", + "type": "string" + } + } + }, + "segmentSizeDefault": { + "description": "Default size of a encryption segment", + "type": "number" + }, + "segmentHashAlg": { + "description": "Algorithm used to generate segment hashes", + "type": "string" + }, + "segments": { + "description": "An array of segment objects. Allows for the possibility of assuring integrity over file segments, in addition to the entire payload. Useful for streaming.", + "type": "array", + "items": { + "description": "Segment object. Contains information necessary to validate integrity over a specific byte range of a payload.", + "type": "object", + "properties": { + "hash": { + "description": "Generated hash using the segment hashing algorithm specified in the parent object.", + "type": "string" + }, + "segmentSize": { + "description": "The size of the segment prior to its encryption. Optional field only specified if it differs from the 'segmentSizeDefault', specified above.", + "type": "number" + }, + "encryptedSegmentSize": { + "description": "The size of the segment once it has been encrypted.", + "type": "number" + } + } + } + }, + "encryptedSegmentSizeDefault": { + "description": "Default size of an encrypted segment. TODO: Is this necessary??", + "type": "number" + } + }, + "required": ["rootSignature", "segmentSizeDefault", "segments", "encryptedSegmentSizeDefault"] + }, + "policy": { + "description": "Base64 encoded policy object", + "type": "string" + } + } + }, + "assertions": { + "type": "array", + "description": "An array of objects used to express metadata about the objects in the scope attribute of the assertion. An assertion also supports metadata about the assertion statement for the purposes of indicating any handling instructions pertinent to the statement itself. Also supports encrypted statements and binding the statement with objects in its scope.", + "items": { + "type": "object", + "description": "A single assertion", + "properties": { + "id": { + "description": "A unique local identifier used for binding and signing purposes. Not guaranteed to be unique across multiple TDOs but must be unique within a single instance.", + "type": "string" + }, + "type": { + "description": "Describes the type of assertion ('handling' or 'other').", + "type": "string" + }, + "scope": { + "description": "An enumeration of the object to which the assertion applies ('tdo' or 'payload').", + "type": "string" + }, + "appliesToState": { + "description": "Used to indicate if the statement metadata applies to 'encrypted' or 'unencrypted' data.", + "type": ["string", "null"] + }, + "statement": { + "description": "Intended for access, rights, and/or handling instructions that apply to the scope of the assertion.", + "type": "object", + "properties": { + "format": { + "description": "Describes the payload content encoding format ('xml-structured', 'base64binary', 'string').", + "type": "string" + }, + "value": { + "description": "Payload content encoded in the format specified.", + "type": ["string", "object"] + } + } + }, + "binding": { + "description": "Object describing the assertionBinding. Contains a hash, and an algorithm used.", + "type": "object", + "properties": { + "method": { + "description": "The assertion binding method used encode the signature. Default is 'jws'", + "type": "string" + }, + "signature": { + "description": "This contains a keyed hash that will provide cryptographic integrity on the assertion object, such that it cannot be modified or copied to another TDF, without invalidating the binding. Specifically, you would have to have access to the key in order to overwrite the policy.", + "type": "string" + } + }, + "required": ["method", "signature"] + } + }, + "required": ["id", "type", "scope", "appliesToState", "statement"] + } + } + }, + "required": ["payload", "encryptionInformation"] +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/Fuzzing.java b/sdk/src/test/java/io/opentdf/platform/sdk/Fuzzing.java index 16870ac5..21293406 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/Fuzzing.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/Fuzzing.java @@ -56,4 +56,14 @@ public void fuzzZipRead(FuzzedDataProvider data) { // bug for the fuzzer to find } } + + /** + * {@link SDK#isValidTdf} answers yes or no for malformed input and throws only when the + * channel can't be read. An in-memory channel can always be read, so any exception at all is + * a bug, including an IOException. + */ + @FuzzTest(maxDuration=TEST_DURATION) + public void fuzzIsValidTdf(FuzzedDataProvider data) throws IOException { + SDK.isValidTdf(new SeekableInMemoryByteChannel(data.consumeRemainingAsBytes())); + } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFValidatorTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFValidatorTest.java new file mode 100644 index 00000000..f7cbf243 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFValidatorTest.java @@ -0,0 +1,536 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.JsonElement; +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; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.math.BigInteger; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyPair; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static io.opentdf.platform.sdk.TDFWriter.TDF_MANIFEST_FILE_NAME; +import static io.opentdf.platform.sdk.TDFWriter.TDF_MANIFEST_FILE_NAME_SPEC; +import static io.opentdf.platform.sdk.TDFWriter.TDF_PAYLOAD_FILE_NAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +class TDFValidatorTest { + + /** The SHA-256 of sdk/schema/manifest-lax.schema.json at opentdf/platform tag sdk/v0.35.0. */ + private static final String LAX_SCHEMA_SHA256 = "5e361720f005b23211a37d9e89c2c7ef51bc1b7df3353b82c2a568c3ed33f87d"; + + /** The first lax-schema case: the smallest manifest the lax schema accepts. */ + private static final String MINIMAL_MANIFEST = "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\"," + + "\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{}}"; + + private static final byte[] PAYLOAD = "ciphertext, unchecked".getBytes(StandardCharsets.UTF_8); + + // ---- the vendored schema + + /** + * The schema must be the Go SDK's file, unedited. The sdk build filters resources, so this + * also catches a refresh that the filter would change. + */ + @Test + void theLaxSchemaIsTheGoSdkFileUnchanged() throws IOException, NoSuchAlgorithmException { + try (InputStream schema = TDFValidator.class.getResourceAsStream(TDFValidator.LAX_SCHEMA_RESOURCE)) { + assertThat(schema).isNotNull(); + var digest = MessageDigest.getInstance("SHA-256").digest(schema.readAllBytes()); + assertThat(String.format("%064x", new BigInteger(1, digest))).isEqualTo(LAX_SCHEMA_SHA256); + } + } + + // ---- TDFs this SDK writes + + private static final KeyPair RSA_KAS_KEY = CryptoUtils.generateRSAKeypair(); + private static final KeyPair EC_KAS_KEY = CryptoUtils.generateECKeypair(KeyType.EC256Key.getECCurve().getCurveName()); + + /** Hands out public keys, which is all writing a TDF needs; nothing here decrypts. */ + private static final SDK.KAS PUBLIC_KEY_KAS = new SDK.KAS() { + @Override + public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) { + var key = kasInfo.URL.endsWith("/ec") ? EC_KAS_KEY : RSA_KAS_KEY; + var info = new Config.KASInfo(); + info.URL = kasInfo.URL; + info.KID = "k1"; + info.PublicKey = CryptoUtils.getPublicKeyPEM(key.getPublic()); + return info; + } + + @Override + public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) { + throw new UnsupportedOperationException("validation must not unwrap keys"); + } + + @Override + public KASKeyCache getKeyCache() { + return new KASKeyCache(); + } + + @Override + public void close() { + } + }; + + private static Config.KASInfo kas(String url) { + var info = new Config.KASInfo(); + info.URL = url; + return info; + } + + static Stream tdfsThisSdkWrites() { + var rsa = kas("https://kas.example.com/rsa"); + var ec = kas("https://kas.example.com/ec"); + var assertion = new AssertionConfig(); + assertion.id = "assertion1"; + assertion.type = AssertionConfig.Type.BaseAssertion; + assertion.scope = AssertionConfig.Scope.TrustedDataObj; + assertion.appliesToState = AssertionConfig.AppliesToState.Unencrypted; + assertion.statement = new AssertionConfig.Statement(); + assertion.statement.format = "base64binary"; + assertion.statement.schema = "text"; + assertion.statement.value = "ICAgIDxlZGoOkVkaD4="; + assertion.signingKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, + AesGcm.generateKey()); + return Stream.of( + Arguments.of("RSA wrapped", "wrapped", List.>of(Config.withKasInformation(rsa))), + Arguments.of("EC wrapped", "ec-wrapped", List.>of( + Config.withKasInformation(ec), Config.WithWrappingKeyAlg(KeyType.EC256Key))), + Arguments.of("split across two KASes", "wrapped", List.>of( + Config.withKasInformation(rsa, kas("https://kas2.example.com/rsa")))), + Arguments.of("assertions, metadata and a MIME type", "wrapped", List.>of( + Config.withKasInformation(rsa), Config.withAssertionConfig(assertion), + Config.withSystemMetadataAssertion(), Config.withMetaData("some metadata"), + Config.withMimeType("text/plain"))), + Arguments.of("many segments", "wrapped", List.>of( + Config.withKasInformation(rsa), Config.withSegmentSize(Config.MIN_SEGMENT_SIZE))), + Arguments.of("pre-4.3.0 target mode", "wrapped", List.>of( + Config.withKasInformation(rsa), Config.withTargetMode("4.2.2")))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("tdfsThisSdkWrites") + void tdfsThisSdkWritesAreValid(String shape, String keyAccessType, List> options) + throws Exception { + var config = new ArrayList>(options); + config.add(0, Config.withAutoconfigure(false)); + var tdfConfig = Config.newTDFConfig(config.toArray(Consumer[]::new)); + var out = new ByteArrayOutputStream(); + var payload = new byte[3 * Config.MIN_SEGMENT_SIZE + 17]; + var manifest = new TDF(new FakeServicesBuilder().setKas(PUBLIC_KEY_KAS).build()) + .createTDF(new ByteArrayInputStream(payload), out, tdfConfig).getManifest(); + // the shape is the one named + assertThat(manifest.encryptionInformation.keyAccessObj).extracting(k -> k.keyType).containsOnly(keyAccessType); + + SDK.validateTdf(new SeekableInMemoryByteChannel(out.toByteArray())); + assertThat(SDK.isValidTdf(new SeekableInMemoryByteChannel(out.toByteArray()))).isTrue(); + } + + @Test + void theSampleTdfIsValid() throws IOException { + try (var tdf = Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("sample.txt.tdf"))) { + assertThat(SDK.isValidTdf(new SeekableInMemoryByteChannel(tdf.readAllBytes()))).isTrue(); + } + } + + // ---- the lax schema + + static Stream laxSchemaCases() throws IOException { + try (var cases = new InputStreamReader(Objects.requireNonNull( + TDFValidatorTest.class.getResourceAsStream("lax-manifest-cases.json")), StandardCharsets.UTF_8)) { + var corpus = JsonParser.parseReader(cases).getAsJsonObject().getAsJsonArray("cases"); + var arguments = new ArrayList(); + for (JsonElement element : corpus) { + JsonObject c = element.getAsJsonObject(); + arguments.add(Arguments.of(c.get("name").getAsString(), c.get("manifest").toString(), + c.get("valid").getAsBoolean())); + } + assertThat(arguments).hasSize(42); + return arguments.stream(); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("laxSchemaCases") + void laxSchemaCasesGetTheGoSdkAnswer(String name, String manifest, boolean valid) throws IOException { + assertThat(SDK.isValidTdf(tdfWithManifest(manifest))).as(name).isEqualTo(valid); + } + + @Test + void aSchemaFailureNamesWhereInTheManifestItIs() { + var manifest = MINIMAL_MANIFEST.replace("\"isEncrypted\":true", "\"isEncrypted\":\"yes\""); + assertThatThrownBy(() -> SDK.validateTdf(tdfWithManifest(manifest))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageStartingWith("manifest does not match the lax manifest schema at /payload/isEncrypted: ") + .hasMessageNotContaining("\n"); + } + + @Test + void aManifestThatIsNotAnObjectIsReportedAtTheTopLevel() { + assertThatThrownBy(() -> SDK.validateTdf(tdfWithManifest("[]"))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageStartingWith("manifest does not match the lax manifest schema at the top level: "); + } + + /** + * Known divergence, pinned so that it is noticed when it changes: the lax schema's key access + * type enum has no {@code mlkem-wrapped}, which this SDK (with sdk-pqc-bc), Go and the web SDK + * all write. DSPX-4388 tracks fixing the schema; this flips when a refreshed schema lands. + */ + @Test + void mlkemWrappedKeyAccessIsNotInTheLaxSchemaYet() { + var manifest = MINIMAL_MANIFEST.replace("\"encryptionInformation\":{}", + "\"encryptionInformation\":{\"keyAccess\":[{\"type\":\"mlkem-wrapped\",\"url\":\"https://kas.example.com\"," + + "\"protocol\":\"kas\",\"wrappedKey\":\"AAAA\",\"policyBinding\":\"AAAA\"}]}"); + assertThatThrownBy(() -> SDK.validateTdf(tdfWithManifest(manifest))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageContaining("/encryptionInformation/keyAccess/0/type"); + } + + // ---- choosing the manifest + + @Test + void theSpecManifestNameIsAccepted() throws IOException { + assertThat(SDK.isValidTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), + entry(TDF_MANIFEST_FILE_NAME_SPEC, MINIMAL_MANIFEST)))).isTrue(); + } + + @Test + void theSpecManifestNameWinsWhenBothArePresent() throws IOException { + assertThat(SDK.isValidTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), entry(TDF_MANIFEST_FILE_NAME, "{}"), + entry(TDF_MANIFEST_FILE_NAME_SPEC, MINIMAL_MANIFEST)))).isTrue(); + } + + /** A failing manifest.json is not retried as 0.manifest.json, which would read a superseded manifest. */ + @Test + void thereIsNoFallbackWhenTheSpecManifestFails() throws IOException { + assertThat(SDK.isValidTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), + entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST), entry(TDF_MANIFEST_FILE_NAME_SPEC, "{}")))).isFalse(); + } + + /** + * An archive listing one name twice is refused, as {@link TDFReader} refuses it, in either + * order. Go and the web SDK let the last copy win instead; refusing is what DSPX-4924 asks + * for, since two readers that pick different copies read different policies from one file. + */ + @ParameterizedTest + @ValueSource(strings = {TDF_MANIFEST_FILE_NAME_SPEC, TDF_MANIFEST_FILE_NAME}) + void aDuplicatedManifestEntryIsInvalidInEitherOrder(String name) { + for (var order : List.of(List.of("{}", MINIMAL_MANIFEST), List.of(MINIMAL_MANIFEST, "{}"))) { + var channel = tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), entry(name, order.get(0)), entry(name, order.get(1))); + assertThatThrownBy(() -> SDK.validateTdf(channel)) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageContaining("more than one entry named " + name); + } + } + + @Test + void aDuplicatedPayloadEntryIsInvalid() throws IOException { + assertThat(SDK.isValidTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), + entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST)))).isFalse(); + } + + @Test + void anUnrelatedExtraEntryIsIgnored() throws IOException { + assertThat(SDK.isValidTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), + entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST), entry("something-else", "x")))).isTrue(); + } + + @Test + void aMissingManifestIsInvalid() { + assertThatThrownBy(() -> SDK.validateTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD)))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageContaining("doesn't contain a manifest"); + } + + /** + * Java's reader needs a payload entry, so the check does too. Go's {@code IsValidTdf} and the + * web SDK's {@code isValidTdf} accept a manifest alone. + */ + @Test + void aMissingPayloadIsInvalid() { + assertThatThrownBy(() -> SDK.validateTdf(tdf(entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST)))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageContaining("doesn't contain a payload"); + } + + /** The payload is not read, let alone decrypted or checked against the manifest. */ + @ParameterizedTest + @ValueSource(ints = {0, 1, 28}) + void thePayloadIsNotChecked(int payloadSize) throws IOException { + assertThat(SDK.isValidTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, new byte[payloadSize]), + entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST)))).isTrue(); + } + + // ---- parsing the manifest + + @ParameterizedTest + @ValueSource(strings = {TDF_MANIFEST_FILE_NAME_SPEC, TDF_MANIFEST_FILE_NAME}) + void aByteOrderMarkIsInvalid(String name) { + var withBom = HostileZips.concat(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}, + MINIMAL_MANIFEST.getBytes(StandardCharsets.UTF_8)); + assertThatThrownBy(() -> SDK.validateTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), entry(name, withBom)))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessage("manifest starts with a byte order mark"); + } + + @ParameterizedTest + @ValueSource(strings = {" {}", "{}", "x", "\u0000", " []", "\"more\""}) + void dataAfterTheManifestIsInvalid(String trailing) { + assertThatThrownBy(() -> SDK.validateTdf(tdfWithManifest(MINIMAL_MANIFEST + trailing))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageStartingWith("manifest has data after its JSON value, starting at line 1 column ") + .hasMessageNotContaining("LENIENT"); + } + + @Test + void whitespaceAroundTheManifestIsAllowed() throws IOException { + assertThat(SDK.isValidTdf(tdfWithManifest(" \t\r\n" + MINIMAL_MANIFEST + "\n \t\r\n"))).isTrue(); + } + + /** What Gson's lenient default accepts, and RFC 8259 and Go do not. */ + @ParameterizedTest + @ValueSource(strings = { + "{/* a comment */\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{}}", + "{'payload':{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{}}", + "{payload:{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{}}", + "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{},}", + "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":TRUE},\"encryptionInformation\":{}}", + "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{},\"n\":NaN}", + "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{},\"s\":\"\\'\"}", + "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{},\"s\":\"a\tb\"}", + "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true};\"encryptionInformation\":{}}", + "{\"payload\"=>{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{}}", + ")]}'\n" + MINIMAL_MANIFEST, + }) + void lenientJsonIsInvalid(String manifest) { + assertThatThrownBy(() -> SDK.validateTdf(tdfWithManifest(manifest))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageStartingWith("manifest is not valid JSON: ") + .hasMessageNotContaining("LENIENT") + .hasMessageNotContaining("\n"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "{", "{\"payload\":", "nul"}) + void emptyOrTruncatedJsonIsInvalid(String manifest) { + assertThatThrownBy(() -> SDK.validateTdf(tdfWithManifest(manifest))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageStartingWith("manifest is not valid JSON: "); + } + + /** Go's limit: 10,000 levels are accepted, 10,001 are not. The object around the arrays is one level. */ + @Test + void nestingIsLimitedToGosDepth() throws IOException { + int arrays = TDFValidator.MAX_NESTING_DEPTH - 1; + assertThat(SDK.isValidTdf(tdfWithManifest(withMember("\"deep\":" + "[".repeat(arrays) + "]".repeat(arrays))))) + .isTrue(); + assertThatThrownBy(() -> SDK.validateTdf(tdfWithManifest( + withMember("\"deep\":" + "[".repeat(arrays + 1) + "]".repeat(arrays + 1))))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageStartingWith("manifest is not valid JSON: "); + } + + /** As in Go, a repeated member name is not an error and the last one wins. */ + @Test + void theLastOfARepeatedMemberWins() throws IOException { + var payload = "\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\",\"protocol\":\"zip\",\"isEncrypted\":true}"; + assertThat(SDK.isValidTdf(tdfWithManifest("{\"payload\":1," + payload + ",\"encryptionInformation\":{}}"))) + .isTrue(); + assertThat(SDK.isValidTdf(tdfWithManifest("{" + payload + ",\"payload\":1,\"encryptionInformation\":{}}"))) + .isFalse(); + } + + /** As in Go, bytes that are not UTF-8 become U+FFFD rather than failing the parse. */ + @Test + void malformedUtf8InAStringIsReplacedNotRefused() throws IOException { + // "zip" becomes "zi" followed by a byte that can't start a UTF-8 sequence + int p = MINIMAL_MANIFEST.indexOf("\"zip\"") + 3; + var manifest = HostileZips.concat(MINIMAL_MANIFEST.substring(0, p).getBytes(StandardCharsets.UTF_8), + new byte[]{(byte) 0xFF}, MINIMAL_MANIFEST.substring(p + 1).getBytes(StandardCharsets.UTF_8)); + assertThat(SDK.isValidTdf(tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), entry(TDF_MANIFEST_FILE_NAME, manifest)))) + .isTrue(); + } + + /** + * Gson will not convert a number of more than 10,000 digits, or with an exponent that large, + * so the validator fails on one where the schema looks. That is reported as invalid, not + * thrown. Go accepts such a number. + */ + @Test + void aNumberGsonWontConvertIsInvalidNotThrown() { + var manifest = MINIMAL_MANIFEST.replace("\"isEncrypted\":true", "\"isEncrypted\":true,\"big\":1e99999"); + assertThatThrownBy(() -> SDK.validateTdf(tdfWithManifest(manifest))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageStartingWith("manifest could not be checked against the lax manifest schema: "); + } + + // ---- archive structure + + /** + * Java's reader accepts an archive comment, and so does this check. Go's {@code IsValidTdf} + * refuses one; that divergence is the epic's to settle (DSPX-5348). + */ + @Test + void anArchiveCommentIsAllowed() throws IOException { + var archive = HostileZips.build(List.of(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST))); + var comment = "an archive comment".getBytes(StandardCharsets.UTF_8); + // the comment length is the last field of the end of central directory record + archive[archive.length - 2] = (byte) comment.length; + assertThat(SDK.isValidTdf(new SeekableInMemoryByteChannel(HostileZips.concat(archive, comment)))).isTrue(); + } + + @Test + void anEmptyChannelIsInvalid() throws IOException { + assertThat(SDK.isValidTdf(new SeekableInMemoryByteChannel(new byte[0]))).isFalse(); + } + + @Test + void bytesThatAreNotAZipAreInvalid() throws IOException { + assertThat(SDK.isValidTdf(new SeekableInMemoryByteChannel(MINIMAL_MANIFEST.getBytes(StandardCharsets.UTF_8)))) + .isFalse(); + } + + @Test + void aTruncatedArchiveIsInvalid() throws IOException { + var whole = HostileZips.build(List.of(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST))); + for (int cut = 1; cut < whole.length; cut++) { + var truncated = Arrays.copyOf(whole, whole.length - cut); + assertThat(SDK.isValidTdf(new SeekableInMemoryByteChannel(truncated))).as("cut %d bytes", cut).isFalse(); + } + } + + /** + * A manifest entry that claims more bytes than it has reads on into the next zip record, which + * is not JSON. (The archive can't run out first: the end of central directory record is last.) + */ + @Test + void aManifestEntryClaimingMoreBytesThanItHasIsInvalid() { + var manifest = entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST); + manifest.uncompressedSize = (long) MINIMAL_MANIFEST.length() + 4096; + assertThatThrownBy(() -> SDK.validateTdf(new SeekableInMemoryByteChannel( + HostileZips.build(List.of(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), manifest))))) + .isInstanceOf(SDK.MalformedTDFException.class) + .hasMessageStartingWith("manifest has data after its JSON value, starting at line 1 column "); + } + + @Test + void hostileCentralDirectoriesAreInvalid() { + HostileZips.hostileShapes().forEach((shape, hostile) -> { + var archive = HostileZips.tdfWith(MINIMAL_MANIFEST, hostile); + boolean valid = assertTimeoutPreemptively(Duration.ofSeconds(5), + () -> SDK.isValidTdf(new SeekableInMemoryByteChannel(archive)), shape); + assertThat(valid).as(shape).isFalse(); + }); + } + + /** + * Every input the reader's fuzz targets have kept gets an answer: no hang, and no exception, + * since an in-memory channel never fails to read. + */ + @ParameterizedTest + @ValueSource(strings = {"fuzzTDF", "fuzzZipRead"}) + void everyFuzzingInputGetsAnAnswer(String target) throws IOException, URISyntaxException { + var inputs = Paths.get(Objects.requireNonNull(getClass().getResource("FuzzingInputs/" + target)).toURI()); + try (Stream files = Files.list(inputs)) { + for (var file : (Iterable) files::iterator) { + var bytes = Files.readAllBytes(file); + assertTimeoutPreemptively(Duration.ofSeconds(5), + () -> SDK.isValidTdf(new SeekableInMemoryByteChannel(bytes)), file.toString()); + } + } + } + + // ---- I/O errors + + @Test + void aChannelThatCannotBeReadThrows() { + var channel = new SeekableInMemoryByteChannel(HostileZips.tdfWith(MINIMAL_MANIFEST, e -> { })) { + @Override + public int read(ByteBuffer buf) throws IOException { + throw new IOException("disk on fire"); + } + }; + assertThatThrownBy(() -> SDK.isValidTdf(channel)).isInstanceOf(IOException.class).hasMessage("disk on fire"); + } + + /** A read failure part way through the manifest is an I/O error too, not an invalid manifest. */ + @Test + void aReadFailureInsideTheManifestThrows() { + var manifestName = TDF_MANIFEST_FILE_NAME.getBytes(StandardCharsets.UTF_8); + // the manifest is the first entry, so its data starts right after its local header + long manifestStart = 30 + manifestName.length; + var archive = HostileZips.build(List.of(entry(TDF_MANIFEST_FILE_NAME, MINIMAL_MANIFEST), entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD))); + var channel = new SeekableInMemoryByteChannel(archive) { + @Override + public int read(ByteBuffer buf) throws IOException { + if (position() >= manifestStart && position() < manifestStart + MINIMAL_MANIFEST.length()) { + throw new IOException("disk on fire"); + } + return super.read(buf); + } + }; + assertThatThrownBy(() -> SDK.isValidTdf(channel)).isInstanceOf(IOException.class).hasMessage("disk on fire"); + } + + @Test + void aChannelThatStallsThrows() { + var channel = new SeekableInMemoryByteChannel(HostileZips.tdfWith(MINIMAL_MANIFEST, e -> { })) { + @Override + public int read(ByteBuffer buf) { + return 0; + } + }; + assertThatThrownBy(() -> SDK.isValidTdf(channel)).isInstanceOf(IOException.class) + .hasMessageContaining("returned no data"); + } + + // ---- helpers + + private static String withMember(String member) { + return MINIMAL_MANIFEST.substring(0, MINIMAL_MANIFEST.length() - 1) + "," + member + "}"; + } + + private static HostileZips.Entry entry(String name, String data) { + return entry(name, data.getBytes(StandardCharsets.UTF_8)); + } + + private static HostileZips.Entry entry(String name, byte[] data) { + return new HostileZips.Entry(name, data); + } + + private static SeekableInMemoryByteChannel tdf(HostileZips.Entry... entries) { + return new SeekableInMemoryByteChannel(HostileZips.build(List.of(entries))); + } + + /** A payload, then the given manifest under the name this SDK writes. */ + private static SeekableInMemoryByteChannel tdfWithManifest(String manifest) { + return tdf(entry(TDF_PAYLOAD_FILE_NAME, PAYLOAD), entry(TDF_MANIFEST_FILE_NAME, manifest)); + } +} diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/sample.tdf b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/sample.tdf new file mode 100644 index 00000000..cc27081a Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/sample.tdf differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/sample.txt.tdf b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/sample.txt.tdf new file mode 100644 index 00000000..2bb81265 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/sample.txt.tdf differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/seed-both-manifest-names b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/seed-both-manifest-names new file mode 100644 index 00000000..83a18506 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/seed-both-manifest-names differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/seed-minimal-lax-manifest b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/seed-minimal-lax-manifest new file mode 100644 index 00000000..95d4fa09 Binary files /dev/null and b/sdk/src/test/resources/io/opentdf/platform/sdk/FuzzingInputs/fuzzIsValidTdf/seed-minimal-lax-manifest differ diff --git a/sdk/src/test/resources/io/opentdf/platform/sdk/lax-manifest-cases.json b/sdk/src/test/resources/io/opentdf/platform/sdk/lax-manifest-cases.json new file mode 100644 index 00000000..a52dd329 --- /dev/null +++ b/sdk/src/test/resources/io/opentdf/platform/sdk/lax-manifest-cases.json @@ -0,0 +1,686 @@ +{ + "description": "Lax manifest schema cases. Each manifest is checked inside an otherwise well-formed TDF; valid is the answer the Go SDK v0.35.0 IsValidTdf gives.", + "source": "https://github.com/opentdf/web-sdk/blob/9ded9d69da75273be9f1540ba1e6380a1a6d8a40/lib/tests/mocks/validation/manifest-cases.ts (opentdf/web-sdk#1104), exported to JSON unchanged", + "note": "A snapshot. The cross-SDK validity suite in opentdf/tests (DSPX-5349) is meant to own this corpus; cut this file down to a smoke test once it does.", + "cases": [ + { + "name": "minimal lax manifest", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {} + }, + "valid": true + }, + { + "name": "missing payload declaration", + "manifest": { + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "missing encryption information", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + } + }, + "valid": false + }, + { + "name": "null manifest", + "manifest": null, + "valid": false + }, + { + "name": "array manifest", + "manifest": [], + "valid": false + }, + { + "name": "empty object", + "manifest": {}, + "valid": false + }, + { + "name": "missing payload type", + "manifest": { + "payload": { + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "missing payload url", + "manifest": { + "payload": { + "type": "reference", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "missing payload protocol", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "isEncrypted": true + }, + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "missing payload isEncrypted", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip" + }, + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "wrong payload type type", + "manifest": { + "payload": { + "type": 3, + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "wrong payload url type", + "manifest": { + "payload": { + "type": "reference", + "url": 3, + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "wrong payload mimeType type", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true, + "mimeType": 3 + }, + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "wrong encrypted flag type", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": "true" + }, + "encryptionInformation": {} + }, + "valid": false + }, + { + "name": "unencrypted declaration", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": false + }, + "encryptionInformation": {} + }, + "valid": true + }, + { + "name": "unconstrained protocol", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": 42, + "isEncrypted": true + }, + "encryptionInformation": {} + }, + "valid": true + }, + { + "name": "unconstrained payload kind", + "manifest": { + "payload": { + "type": "other", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {} + }, + "valid": true + }, + { + "name": "nullable specification version", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true, + "tdf_spec_version": null + }, + "encryptionInformation": {} + }, + "valid": true + }, + { + "name": "unknown fields allowed", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {}, + "futureFeature": { + "enabled": true + } + }, + "valid": true + }, + { + "name": "null encryption information", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": null + }, + "valid": false + }, + { + "name": "invalid policy type", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "policy": {} + } + }, + "valid": false + }, + { + "name": "empty policy allowed", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "policy": "" + } + }, + "valid": true + }, + { + "name": "empty key access list", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "keyAccess": [] + } + }, + "valid": true + }, + { + "name": "partial key access item", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "keyAccess": [ + {} + ] + } + }, + "valid": true + }, + { + "name": "invalid key access kind", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "keyAccess": [ + { + "type": "invalid" + } + ] + } + }, + "valid": false + }, + { + "name": "hybrid key access", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "keyAccess": [ + { + "type": "hybrid-wrapped" + } + ] + } + }, + "valid": true + }, + { + "name": "invalid key access binding", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "keyAccess": [ + { + "policyBinding": { + "alg": "HS256" + } + } + ] + } + }, + "valid": false + }, + { + "name": "string policy binding", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "keyAccess": [ + { + "policyBinding": "hash" + } + ] + } + }, + "valid": true + }, + { + "name": "missing method streamable", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "method": { + "algorithm": "AES-256-GCM" + } + } + }, + "valid": false + }, + { + "name": "unconstrained encryption algorithm", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "method": { + "algorithm": "future", + "isStreamable": false + } + } + }, + "valid": true + }, + { + "name": "wrong streamable type", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "method": { + "algorithm": "AES-256-GCM", + "isStreamable": 1 + } + } + }, + "valid": false + }, + { + "name": "lax integrity declarations", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "integrityInformation": { + "rootSignature": { + "alg": "future", + "sig": "" + }, + "segmentSizeDefault": 1, + "encryptedSegmentSizeDefault": 29, + "segments": [ + { + "encryptedSegmentSize": -1.5 + } + ] + } + } + }, + "valid": true + }, + { + "name": "missing integrity rootSignature", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "integrityInformation": { + "segmentSizeDefault": 1, + "encryptedSegmentSizeDefault": 29, + "segments": [ + { + "encryptedSegmentSize": -1.5 + } + ] + } + } + }, + "valid": false + }, + { + "name": "missing integrity segmentSizeDefault", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "integrityInformation": { + "rootSignature": { + "alg": "future", + "sig": "" + }, + "encryptedSegmentSizeDefault": 29, + "segments": [ + { + "encryptedSegmentSize": -1.5 + } + ] + } + } + }, + "valid": false + }, + { + "name": "missing integrity encryptedSegmentSizeDefault", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "integrityInformation": { + "rootSignature": { + "alg": "future", + "sig": "" + }, + "segmentSizeDefault": 1, + "segments": [ + { + "encryptedSegmentSize": -1.5 + } + ] + } + } + }, + "valid": false + }, + { + "name": "missing integrity segments", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "integrityInformation": { + "rootSignature": { + "alg": "future", + "sig": "" + }, + "segmentSizeDefault": 1, + "encryptedSegmentSizeDefault": 29 + } + } + }, + "valid": false + }, + { + "name": "invalid segment field type", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "integrityInformation": { + "rootSignature": { + "alg": "future", + "sig": "" + }, + "segmentSizeDefault": 1, + "encryptedSegmentSizeDefault": 29, + "segments": [ + { + "hash": 3 + } + ] + } + } + }, + "valid": false + }, + { + "name": "empty root signature", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": { + "integrityInformation": { + "rootSignature": {}, + "segmentSizeDefault": 1, + "encryptedSegmentSizeDefault": 29, + "segments": [ + { + "encryptedSegmentSize": -1.5 + } + ] + } + } + }, + "valid": true + }, + { + "name": "empty assertion list", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {}, + "assertions": [] + }, + "valid": true + }, + { + "name": "incomplete assertion", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {}, + "assertions": [ + {} + ] + }, + "valid": false + }, + { + "name": "lax assertion", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {}, + "assertions": [ + { + "id": "a", + "type": "other", + "scope": "payload", + "appliesToState": null, + "statement": { + "value": {} + } + } + ] + }, + "valid": true + }, + { + "name": "invalid assertion binding", + "manifest": { + "payload": { + "type": "reference", + "url": "0.payload", + "protocol": "zip", + "isEncrypted": true + }, + "encryptionInformation": {}, + "assertions": [ + { + "id": "a", + "type": "other", + "scope": "payload", + "appliesToState": null, + "statement": { + "value": {} + }, + "binding": { + "method": "jws" + } + } + ] + }, + "valid": false + } + ] +}