| // Copyright 2026 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // https://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| // |
| |
| #include "bindings/merkle_validator.h" |
| |
| #include <algorithm> |
| #include <cctype> |
| #include <memory> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include "absl/log/log.h" |
| #include "absl/status/status.h" |
| #include "absl/status/status_matchers.h" |
| #include "absl/strings/str_cat.h" |
| #include "absl/strings/str_format.h" |
| #include "absl/strings/string_view.h" |
| #include "constants/status_codes.h" |
| #include "crypto/algorithms.h" |
| #include "crypto/hash.h" |
| #include "formats/bmff/test_utils.h" |
| #include "gmock/gmock.h" |
| #include "gtest/gtest.h" |
| #include "proto/bmff_based_hash_assertion.pb.h" |
| #include "riegeli/bytes/reader.h" |
| #include "riegeli/bytes/string_reader.h" |
| #include "testing/cbor_utils.h" |
| #include "testing/proto_test_utils.h" |
| #include "utils/status_tracker.h" |
| #include "validator/tracker.h" |
| |
| namespace credentio { |
| namespace { |
| |
| using ::absl_testing::IsOk; |
| using ::absl_testing::StatusIs; |
| using ::credentio_testing::ParseTextProtoOrDie; |
| using ::testing::ElementsAreArray; |
| using ::testing::IsEmpty; |
| |
| class TrackingStatusTracker : public StatusTracker { |
| public: |
| TrackingStatusTracker() = default; |
| ~TrackingStatusTracker() override = default; |
| |
| struct SuccessRecord { |
| SuccessStatusCode code; |
| std::string url = ""; |
| std::string explanation = ""; |
| |
| bool operator==(const SuccessRecord& other) const { |
| return code == other.code && url == other.url && |
| explanation == other.explanation; |
| } |
| }; |
| |
| struct FailureRecord { |
| FailureStatusCode code; |
| std::string url = ""; |
| std::string explanation = ""; |
| |
| bool operator==(const FailureRecord& other) const { |
| return code == other.code && url == other.url && |
| explanation == other.explanation; |
| } |
| }; |
| |
| struct InformationalRecord { |
| InformationalStatusCode code; |
| std::string url = ""; |
| std::string explanation = ""; |
| |
| bool operator==(const InformationalRecord& other) const { |
| return code == other.code && url == other.url && |
| explanation == other.explanation; |
| } |
| }; |
| |
| void RecordSuccess(SuccessStatusCode code, |
| ValidationTracker::RecordOptions options) override { |
| successes_.push_back(SuccessRecord{ |
| .code = code, |
| .url = std::string(options.url), |
| .explanation = std::string(options.explanation), |
| }); |
| }; |
| void RecordFailure(FailureStatusCode code, |
| ValidationTracker::RecordOptions options) override { |
| failures_.push_back(FailureRecord{ |
| .code = code, |
| .url = std::string(options.url), |
| .explanation = std::string(options.explanation), |
| }); |
| }; |
| void RecordInformational(InformationalStatusCode code, |
| ValidationTracker::RecordOptions options) override { |
| informationals_.push_back(InformationalRecord{ |
| .code = code, |
| .url = std::string(options.url), |
| .explanation = std::string(options.explanation), |
| }); |
| }; |
| |
| std::vector<SuccessRecord> successes_; |
| std::vector<FailureRecord> failures_; |
| std::vector<InformationalRecord> informationals_; |
| }; |
| |
| class JoiningHasherFactoryProvider : public HasherFactoryProvider { |
| public: |
| class JoiningHasherFactory : public HasherFactory { |
| public: |
| class JoiningHasher : public Hasher { |
| public: |
| void Update(absl::string_view content) override { |
| digest_ = absl::StrCat(digest_, content); |
| |
| std::replace_if( |
| digest_.begin(), digest_.end(), |
| [](char c) { return !std::isalnum(static_cast<unsigned char>(c)); }, |
| '_'); |
| } |
| |
| std::string Digest() override { return digest_; } |
| |
| protected: |
| std::string digest_ = ""; |
| }; |
| |
| absl::StatusOr<std::unique_ptr<Hasher>> Create() const override { |
| return std::make_unique<JoiningHasher>(); |
| }; |
| HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; }; |
| }; |
| |
| absl::StatusOr<std::unique_ptr<HasherFactory>> Create( |
| absl::string_view algo) const override { |
| if (algo == "error") { |
| return absl::InvalidArgumentError( |
| absl::StrFormat("unsupported hash algorithm: %s", algo)); |
| } |
| return std::make_unique<JoiningHasherFactory>(); |
| } |
| }; |
| |
| struct MerkleValidatorTestCase { |
| std::string name; |
| std::string file_contents; |
| BmffBasedHashAssertion assertion; |
| absl::Status expected_status; |
| std::vector<TrackingStatusTracker::FailureRecord> expected_failures; |
| }; |
| |
| class MerkleValidatorTest |
| : public ::testing::TestWithParam<MerkleValidatorTestCase> { |
| public: |
| void SetUp() override { |
| auto input_reader = |
| std::make_unique<riegeli::StringReader<>>(GetParam().file_contents); |
| ASSERT_THAT(input_reader->status(), IsOk()); |
| input_ = std::move(input_reader); |
| |
| factory_ = std::make_unique<JoiningHasherFactoryProvider>(); |
| assertion_uri_ = "assertion_uri"; |
| |
| validator_ = std::make_unique<MerkleValidator>( |
| *input_, GetParam().assertion, assertion_uri_, |
| std::make_unique<JoiningHasherFactoryProvider>()); |
| } |
| |
| std::unique_ptr<riegeli::Reader> input_; |
| std::unique_ptr<HasherFactoryProvider> factory_; |
| std::string assertion_uri_; |
| std::unique_ptr<MerkleValidator> validator_; |
| }; |
| |
| TEST_P(MerkleValidatorTest, Validate) { |
| TrackingStatusTracker tracker; |
| |
| EXPECT_THAT(validator_->Validate(tracker), |
| StatusIs(GetParam().expected_status.code(), |
| GetParam().expected_status.message())); |
| |
| // Successes are not marked within the MerkleValidator. |
| EXPECT_THAT(tracker.successes_, IsEmpty()); |
| EXPECT_THAT(tracker.failures_, |
| ElementsAreArray(GetParam().expected_failures)); |
| EXPECT_THAT(tracker.informationals_, IsEmpty()); |
| } |
| |
| INSTANTIATE_TEST_SUITE_P( |
| MerkleValidatorTests, MerkleValidatorTest, |
| ::testing::Values( |
| MerkleValidatorTestCase{ |
| .name = "FailsWithInvalidInput", |
| .file_contents = "this_is_not_a_valid_bmff_file", |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { count: 1 fixed_block_size: 10 } |
| )pb"), |
| .expected_status = |
| absl::InternalError("failed to extract leaf data"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kGoogleInternalError, |
| .url = "assertion_uri", |
| .explanation = "truncated BMFF box; at byte 8"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsMdatCountNotMatching", |
| .file_contents = |
| absl::StrCat(credentio_testing::Box("mdat", "data_one"), |
| credentio_testing::Box("mdat", "data_two")), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { count: 1 fixed_block_size: 10 } |
| )pb"), |
| .expected_status = absl::InternalError( |
| "number of mdat atoms does not match the " |
| "number of merkle maps"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kAssertionBmffHashMalformed, |
| .url = "assertion_uri", |
| .explanation = "number of mdat atoms does not match " |
| "the number of merkle maps"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsFragmentedUnsupported", |
| .file_contents = credentio_testing::Box("mdat", "abcdef"), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| count: 2 |
| init_hash: "only_for_fragmented" |
| fixed_block_size: 3 |
| } |
| )pb"), |
| .expected_status = absl::UnimplementedError( |
| "fragmented merkle validations are not supported yet"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kGoogleInternalError, |
| .url = "assertion_uri", |
| .explanation = |
| "fragmented merkle validations are not supported yet"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsHashesBelowLeafs", |
| .file_contents = credentio_testing::Box("mdat", "abcdef"), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| count: 3 |
| fixed_block_size: 10 |
| hashes: "a" |
| hashes: "b" |
| hashes: "c" |
| hashes: "d" |
| hashes: "e" |
| hashes: "f" |
| } |
| )pb"), |
| .expected_status = absl::InvalidArgumentError( |
| "hashes row size is larger than the leaf row size"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kAssertionBmffHashMalformed, |
| .url = "assertion_uri", |
| .explanation = |
| "hashes row size is larger than the leaf row size"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsLeafSizesTooLarge", |
| .file_contents = credentio_testing::Box("mdat", "abcdef"), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { count: 1 variable_block_sizes: 10 hashes: "abcdef" } |
| )pb"), |
| .expected_status = absl::InvalidArgumentError( |
| "merkle map's variable block sizes do " |
| "not sum to the mdat box size"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kAssertionBmffHashMalformed, |
| .url = "assertion_uri", |
| .explanation = "merkle map's variable block sizes do not sum " |
| "to the mdat box size"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsLeafHashMismatch", |
| .file_contents = credentio_testing::Box("mdat", "abcdef"), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| count: 3 |
| fixed_block_size: 2 |
| hashes: "wrong_hash" |
| hashes: "cd" |
| hashes: "ef" |
| } |
| )pb"), |
| .expected_status = absl::InternalError("merkle map hash mismatch"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kAssertionBmffHashMismatch, |
| .url = "assertion_uri", |
| .explanation = "merkle map hash mismatch at index 0: " |
| "Expected: wrong_hash, Actual: ab"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsAuxiliaryDataBeforeLastMdat", |
| .file_contents = []() -> std::string { |
| std::string aux_box = cbor::FromJson(R"json({ |
| "hashes": [ "b64'Zm9v'" ], |
| "localId": 1, |
| "location": 0, |
| "uniqueId": 1 |
| })json"); |
| return absl::StrCat(credentio_testing::Box( |
| "uuid", credentio_testing::C2paBoxPayload( |
| "merkle", aux_box)), |
| credentio_testing::Box("mdat", "abcdef")); |
| }(), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| count: 7 |
| fixed_block_size: 2 |
| hashes: "wrong_hash" |
| hashes: "cd" |
| hashes: "ef" |
| } |
| )pb"), |
| .expected_status = |
| absl::InternalError("failed to extract leaf data"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kGoogleInternalError, |
| .url = "assertion_uri", |
| .explanation = |
| "encountered auxiliary atom before the first mdat atom"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsIncorrectAuxiliaryCount", |
| .file_contents = credentio_testing::Box("mdat", "abcdef"), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| count: 3 |
| fixed_block_size: 2 |
| hashes: "wrong_hash" |
| hashes: "cd" |
| hashes: "ef" |
| } |
| )pb"), |
| .expected_status = absl::InternalError("merkle map hash mismatch"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kAssertionBmffHashMismatch, |
| .url = "assertion_uri", |
| .explanation = "merkle map hash mismatch at index 0: " |
| "Expected: wrong_hash, Actual: ab"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsUnsupportedHashAlgorithm", |
| .file_contents = credentio_testing::Box("mdat", "abcdef"), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "error" |
| hash: "unused" |
| merkles { |
| count: 3 |
| fixed_block_size: 2 |
| hashes: "ab" |
| hashes: "cd" |
| hashes: "ef" |
| } |
| )pb"), |
| .expected_status = |
| absl::InvalidArgumentError("unsupported hash algorithm: error"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kAssertionBmffHashMalformed, |
| .url = "assertion_uri", |
| .explanation = "unsupported hash algorithm: error"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsInvalidHashWithAuxiliaryData", |
| .file_contents = []() -> std::string { |
| std::string aux_box_1 = cbor::FromJson(R"json({ |
| "hashes": [ "b64'ZGVm'" ], |
| "localId": 1, |
| "location": 0, |
| "uniqueId": 1 |
| })json"); |
| std::string aux_box_2 = cbor::FromJson(R"json({ |
| "hashes": [ "b64'YWJj'" ], |
| "localId": 1, |
| "location": 1, |
| "uniqueId": 1 |
| })json"); |
| return absl::StrCat(credentio_testing::Box("mdat", "abcdef"), |
| credentio_testing::Box( |
| "uuid", credentio_testing::C2paBoxPayload( |
| "merkle", aux_box_1)), |
| credentio_testing::Box( |
| "uuid", credentio_testing::C2paBoxPayload( |
| "merkle", aux_box_2))); |
| }(), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| unique_id: 1 |
| local_id: 1 |
| count: 2 |
| fixed_block_size: 3 |
| hashes: "wrong_hash" |
| } |
| )pb"), |
| .expected_status = absl::InternalError("merkle map hash mismatch"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kAssertionBmffHashMismatch, |
| .url = "assertion_uri", |
| .explanation = "merkle map hash mismatch at index 0: " |
| "Expected: wrong_hash, Actual: abcdef"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsBoxSizeTooSmallForMetadataHeaders", |
| .file_contents = []() -> std::string { |
| std::string aux_box_1 = cbor::FromJson(R"json({ |
| "hashes": [ "b64'ZGVm'" ], |
| "localId": 1, |
| "location": 0, |
| "uniqueId": 1 |
| })json"); |
| return absl::StrCat( |
| credentio_testing::Box("mdat", "abcdef"), |
| credentio_testing::BadBox( |
| "uuid", |
| credentio_testing::C2paBoxPayload("merkle", aux_box_1), |
| 28)); |
| }(), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| unique_id: 1 |
| local_id: 1 |
| count: 2 |
| fixed_block_size: 3 |
| hashes: "wrong_hash" |
| } |
| )pb"), |
| .expected_status = |
| absl::InternalError("failed to extract leaf data"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kGoogleInternalError, |
| .url = "assertion_uri", |
| .explanation = "box size too small for metadata headers"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsMdatBoxSizeSmallerThanHeaderSize", |
| .file_contents = std::string( |
| "\x00\x00\x00\x01mdat\x00\x00\x00\x00\x00\x00\x00\x08", 16), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { count: 1 fixed_block_size: 2 hashes: "11" } |
| )pb"), |
| .expected_status = |
| absl::InternalError("failed to extract leaf data"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kGoogleInternalError, |
| .url = "assertion_uri", |
| .explanation = "kInvalidData; box size too small for header; " |
| "at byte 16"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "FailsTruncatedMerkleCrashPrevention", |
| .file_contents = credentio_testing::Box("mdat", |
| std::string(28, 'a')), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| count: 14 |
| fixed_block_size: 2 |
| hashes: "11" |
| hashes: "22" |
| hashes: "33" |
| } |
| )pb"), |
| .expected_status = absl::InvalidArgumentError( |
| "hashes count is not equal to the expected hashes count: " |
| "Hashes Count: 3, Leaf Count: 14, Expected Hashes Count: 4"), |
| .expected_failures = |
| {{.code = FailureStatusCode::kAssertionBmffHashMalformed, |
| .url = "assertion_uri", |
| .explanation = |
| "hashes count is not equal to the expected hashes count: " |
| "Hashes Count: 3, Leaf Count: 14, Expected Hashes Count: " |
| "4"}}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "SuccessFixedBlockSizeWithHashesAsLeafRow", |
| .file_contents = credentio_testing::Box("mdat", "abcdef"), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| count: 3 |
| fixed_block_size: 2 |
| hashes: "ab" |
| hashes: "cd" |
| hashes: "ef" |
| } |
| )pb"), |
| .expected_status = absl::OkStatus(), |
| .expected_failures = {}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "SuccessVariableBlockSizeWithHashesAsLeafRow", |
| .file_contents = credentio_testing::Box("mdat", "abcdef"), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| count: 2 |
| variable_block_sizes: 4 |
| variable_block_sizes: 2 |
| hashes: "abcd" |
| hashes: "ef" |
| } |
| )pb"), |
| .expected_status = absl::OkStatus(), |
| .expected_failures = {}, |
| }, |
| MerkleValidatorTestCase{ |
| .name = "SuccessHashWithAuxiliaryData", |
| .file_contents = []() -> std::string { |
| std::string aux_box_1 = cbor::FromJson(R"json({ |
| "hashes": [ "b64'ZGVm'" ], |
| "localId": 1, |
| "location": 0, |
| "uniqueId": 1 |
| })json"); |
| std::string aux_box_2 = cbor::FromJson(R"json({ |
| "hashes": [ "b64'YWJj'" ], |
| "localId": 1, |
| "location": 1, |
| "uniqueId": 1 |
| })json"); |
| return absl::StrCat(credentio_testing::Box("mdat", "abcdef"), |
| credentio_testing::Box( |
| "uuid", credentio_testing::C2paBoxPayload( |
| "merkle", aux_box_1)), |
| credentio_testing::Box( |
| "uuid", credentio_testing::C2paBoxPayload( |
| "merkle", aux_box_2))); |
| }(), |
| .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb( |
| alg: "sha256" |
| hash: "unused" |
| merkles { |
| unique_id: 1 |
| local_id: 1 |
| count: 2 |
| fixed_block_size: 3 |
| hashes: "abcdef" |
| } |
| )pb"), |
| .expected_status = absl::OkStatus(), |
| .expected_failures = {}}), |
| [](const testing::TestParamInfo<MerkleValidatorTestCase>& info) { |
| return info.param.name; |
| }); |
| |
| using ::testing::NotNull; |
| |
| TEST(DefaultHasherFactoryProviderTest, CreateSha256) { |
| DefaultHasherFactoryProvider provider; |
| auto factory = provider.Create("sha256"); |
| ASSERT_THAT(factory, IsOk()); |
| EXPECT_THAT(*factory, NotNull()); |
| EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha256); |
| } |
| |
| TEST(DefaultHasherFactoryProviderTest, CreateSha384) { |
| DefaultHasherFactoryProvider provider; |
| auto factory = provider.Create("sha384"); |
| ASSERT_THAT(factory, IsOk()); |
| EXPECT_THAT(*factory, NotNull()); |
| EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha384); |
| } |
| |
| TEST(DefaultHasherFactoryProviderTest, CreateSha512) { |
| DefaultHasherFactoryProvider provider; |
| auto factory = provider.Create("sha512"); |
| ASSERT_THAT(factory, IsOk()); |
| EXPECT_THAT(*factory, NotNull()); |
| EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha512); |
| } |
| |
| TEST(DefaultHasherFactoryProviderTest, CreateInvalid) { |
| DefaultHasherFactoryProvider provider; |
| EXPECT_THAT(provider.Create("invalid"), |
| StatusIs(absl::StatusCode::kInvalidArgument)); |
| } |
| |
| } // namespace |
| } // namespace credentio |