| // 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 <cstdint> |
| #include <memory> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include "absl/container/flat_hash_map.h" |
| #include "absl/status/status.h" |
| #include "absl/status/status_macros.h" |
| #include "absl/status/statusor.h" |
| #include "absl/strings/str_format.h" |
| #include "absl/strings/string_view.h" |
| #include "bindings/input_hasher.h" |
| #include "bindings/merkle.h" |
| #include "cbor/cbor.h" |
| #include "cbor/parse.h" |
| #include "constants/labels.h" |
| #include "constants/status_codes.h" |
| #include "crypto/hash.h" |
| #include "formats/bmff/box_header.h" |
| #include "proto/bmff_based_hash_assertion.cbor.h" |
| #include "proto/bmff_based_hash_assertion.pb.h" |
| #include "riegeli/bytes/reader.h" |
| #include "utils/riegeli.h" |
| #include "utils/status_tracker.h" |
| |
| namespace credentio { |
| |
| namespace { |
| |
| std::string GetMerkleKey(int64_t unique_id, int64_t local_id) { |
| return absl::StrFormat("%d-%d", unique_id, local_id); |
| } |
| |
| absl::StatusOr<std::string> ReadPurpose(riegeli::Reader& contents) { |
| std::string purpose; |
| if (!ReadNullTerminatedString(contents, 20, purpose)) { |
| return contents.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof")); |
| } |
| return purpose; |
| } |
| |
| absl::StatusOr<BmffMerkleMap> ReadBmffMerkleMap(riegeli::Reader& contents, |
| int64_t length) { |
| constexpr int64_t kMaxMerkleMapSize = 10 * 1024 * 1024; // 10 MiB |
| if (length < 0 || length > kMaxMerkleMapSize) { |
| return absl::InvalidArgumentError( |
| absl::StrFormat("invalid length for merkle map: %d", length)); |
| } |
| std::string aux_box_raw; |
| if (!contents.Read(length, aux_box_raw)) { |
| return absl::InternalError("failed to read auxiliary merkle map"); |
| } |
| |
| BmffMerkleMap aux_box; |
| ABSL_ASSIGN_OR_RETURN(auto aux_box_cbor, cbor::Parse(aux_box_raw)); |
| ABSL_ASSIGN_OR_RETURN(auto aux_box_cbor_map, aux_box_cbor->AsMap()); |
| ABSL_RETURN_IF_ERROR(cbor::ToProto(aux_box_cbor_map, &aux_box)); |
| return aux_box; |
| } |
| |
| absl::StatusOr<std::string> ComputeLeafHash(const HasherFactory& factory, |
| riegeli::Reader& contents, |
| int64_t offset, int64_t length) { |
| ABSL_ASSIGN_OR_RETURN(auto hasher, factory.Create()); |
| ABSL_ASSIGN_OR_RETURN(auto input_hasher, |
| InputHasher::Create(std::move(hasher))); |
| ABSL_RETURN_IF_ERROR(input_hasher->Update(contents, offset, length)); |
| return input_hasher->Digest(); |
| } |
| |
| absl::StatusOr<std::string> JoinHashes(const HasherFactory& factory, |
| absl::string_view hash1, |
| absl::string_view hash2) { |
| ABSL_ASSIGN_OR_RETURN(auto hasher, factory.Create()); |
| hasher->Update(hash1); |
| hasher->Update(hash2); |
| return hasher->Digest(); |
| } |
| |
| struct LeafData { |
| std::vector<BmffBoxHeader> mdat_atoms; |
| absl::flat_hash_map<std::string, std::vector<BmffMerkleMap>> auxiliary_data; |
| }; |
| |
| absl::StatusOr<LeafData> ExtractLeafData(riegeli::Reader& contents) { |
| LeafData result; |
| |
| if (!contents.Seek(0) || contents.pos() != 0) { |
| return absl::InternalError("failed to seek to start of file"); |
| } |
| auto status = IterateOverBmffBoxes( |
| contents, |
| [&result, |
| &contents](const BmffBoxHeader& header) -> absl::StatusOr<bool> { |
| // Non-fragmented assets have 1 merkle tree per `mdat` |
| // Fragmented assets could have merkle tree per track |
| // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_general_20 |
| if (header.type == "mdat") { |
| // Non-fragmented assets require auxiliary data after the last mdat. |
| // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_non_fragmented_asset_that_can_be_validated_piecewise |
| if (!result.auxiliary_data.empty()) { |
| return absl::InvalidArgumentError( |
| "encountered auxiliary atom before the first mdat atom"); |
| } |
| result.mdat_atoms.push_back(header); |
| } |
| if (header.type == "uuid" && |
| header.user_type == credentio::kC2paBmffBoxUuid) { |
| // Read next string to determine the purpose of the uuid atom. |
| ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(contents)); |
| |
| if (purpose == "merkle") { |
| uint64_t end_of_box = header.start + header.box_size; |
| if (contents.pos() >= end_of_box) { |
| return absl::InvalidArgumentError( |
| "box size too small for metadata headers"); |
| } |
| uint64_t remaining_data = end_of_box - contents.pos(); |
| ABSL_ASSIGN_OR_RETURN(auto aux_box, |
| ReadBmffMerkleMap(contents, remaining_data)); |
| |
| result |
| .auxiliary_data[GetMerkleKey(aux_box.unique_id(), |
| aux_box.local_id())] |
| .push_back(aux_box); |
| } |
| } |
| return true; |
| }); |
| ABSL_RETURN_IF_ERROR(status); |
| return result; |
| } |
| |
| } // namespace |
| |
| absl::Status MerkleValidator::ValidateMerkleMap( |
| const BmffMerkle& merkle, const BmffBoxHeader& mdat_atom, |
| std::vector<BmffMerkleMap> auxiliary_merkle_maps, |
| absl::string_view fallback_algo, StatusTracker& tracker) const { |
| if (merkle.has_init_hash()) { |
| tracker.RecordFailure( |
| FailureStatusCode::kGoogleInternalError, |
| {.url = assertion_uri_, |
| .explanation = "fragmented merkle validations are not supported yet"}); |
| return absl::UnimplementedError( |
| "fragmented merkle validations are not supported yet"); |
| } |
| |
| // Derive the full row sizes and number of rows away and validates the counts. |
| absl::StatusOr<DerivedTreeData> derived_data = DeriveMerkleTreeData( |
| merkle.count(), merkle.hashes_size(), auxiliary_merkle_maps.size()); |
| if (!derived_data.ok()) { |
| tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMalformed, |
| {.url = assertion_uri_, |
| .explanation = derived_data.status().message()}); |
| return derived_data.status(); |
| } |
| |
| uint64_t mdat_offset_adjustment = 0; |
| for (const auto& exclusion : assertion_.exclusions()) { |
| if (exclusion.xpath() == "/mdat" && !exclusion.subsets().empty()) { |
| mdat_offset_adjustment = exclusion.subsets(0).offset(); |
| break; |
| } |
| } |
| |
| uint64_t header_or_adjustment_size = |
| std::max<uint64_t>(mdat_atom.header_size, mdat_offset_adjustment); |
| |
| if (mdat_atom.box_size < header_or_adjustment_size) { |
| tracker.RecordFailure( |
| FailureStatusCode::kAssertionBmffHashMalformed, |
| {.url = assertion_uri_, |
| .explanation = "mdat atom box_size is smaller than header_size or " |
| "exclusion adjustment"}); |
| return absl::InvalidArgumentError( |
| "mdat atom box_size is smaller than header_size or exclusion " |
| "adjustment"); |
| } |
| |
| // Calculate and validate the lengths of each leaf using net payload size. |
| uint64_t net_mdat_payload_size = |
| mdat_atom.box_size - header_or_adjustment_size; |
| absl::StatusOr<std::vector<int64_t>> leaf_sizes = |
| DeriveMerkleBlockSizes(merkle, net_mdat_payload_size); |
| if (!leaf_sizes.ok()) { |
| tracker.RecordFailure( |
| FailureStatusCode::kAssertionBmffHashMalformed, |
| {.url = assertion_uri_, .explanation = leaf_sizes.status().message()}); |
| return leaf_sizes.status(); |
| } |
| |
| // Based on the above calculations, we ensure the assertion is not malformed, |
| // now compute and compare the leaf hashes. |
| uint64_t offset = mdat_atom.start + header_or_adjustment_size; |
| for (int64_t i = 0; i < merkle.count(); ++i) { |
| absl::string_view algo = merkle.has_alg() ? merkle.alg() : fallback_algo; |
| absl::StatusOr<std::unique_ptr<HasherFactory>> factory = |
| factory_provider_->Create(algo); |
| if (!factory.ok()) { |
| tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMalformed, |
| {.url = assertion_uri_, |
| .explanation = absl::StrFormat( |
| "unsupported hash algorithm: %s", algo)}); |
| return absl::InvalidArgumentError( |
| absl::StrFormat("unsupported hash algorithm: %s", algo)); |
| } |
| |
| absl::StatusOr<std::string> leaf_hash = |
| ComputeLeafHash(**factory, contents_, offset, leaf_sizes.value()[i]); |
| offset += leaf_sizes.value()[i]; |
| if (!leaf_hash.ok()) { |
| tracker.RecordFailure( |
| FailureStatusCode::kAssertionBmffHashMalformed, |
| {.url = assertion_uri_, .explanation = leaf_hash.status().message()}); |
| return leaf_hash.status(); |
| } |
| |
| std::string computed_hash = std::move(leaf_hash.value()); |
| |
| uint64_t running_row_index = i; |
| if (!auxiliary_merkle_maps.empty()) { |
| BmffMerkleMap auxiliary = auxiliary_merkle_maps[i]; |
| int64_t location = auxiliary.location(); |
| uint64_t lvl = 0; |
| int64_t trailing_boundary = (merkle.count() - 1) & ~1LL; |
| for (const auto& hash : auxiliary.hashes()) { |
| bool sibling_on_left = (running_row_index % 2 == 1) || |
| (location >= trailing_boundary && lvl >= 1); |
| if (sibling_on_left) { |
| ABSL_ASSIGN_OR_RETURN(computed_hash, |
| JoinHashes(**factory, hash, computed_hash)); |
| } else { |
| ABSL_ASSIGN_OR_RETURN(computed_hash, |
| JoinHashes(**factory, computed_hash, hash)); |
| } |
| running_row_index >>= 1; |
| ++lvl; |
| } |
| } |
| |
| uint64_t hashes_index = i >> derived_data->delta_rows; |
| if (merkle.hashes(hashes_index) != computed_hash) { |
| tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, |
| {.url = assertion_uri_, |
| .explanation = absl::StrFormat( |
| "merkle map hash mismatch on leaf: %d", i)}); |
| return absl::InternalError("merkle map hash mismatch"); |
| } |
| } |
| return absl::OkStatus(); |
| } |
| |
| absl::Status MerkleValidator::Validate(StatusTracker& tracker) const { |
| absl::StatusOr<LeafData> atoms = ExtractLeafData(contents_); |
| if (!atoms.ok()) { |
| tracker.RecordFailure( |
| FailureStatusCode::kGoogleInternalError, |
| {.url = assertion_uri_, .explanation = atoms.status().message()}); |
| return absl::InternalError("failed to extract leaf data"); |
| } |
| if (atoms->mdat_atoms.size() != assertion_.merkles_size()) { |
| // This should only apply to non-fragmented assets. |
| tracker.RecordFailure( |
| FailureStatusCode::kAssertionBmffHashMalformed, |
| {.url = assertion_uri_, |
| .explanation = |
| "number of mdat atoms does not match the number of merkle maps"}); |
| return absl::InternalError( |
| "number of mdat atoms does not match the number of merkle maps"); |
| } |
| |
| for (int64_t i = 0; i < atoms->mdat_atoms.size(); ++i) { |
| BmffMerkle merkle = assertion_.merkles(i); |
| const auto& aux_maps = atoms->auxiliary_data[GetMerkleKey( |
| merkle.unique_id(), merkle.local_id())]; |
| ABSL_RETURN_IF_ERROR(ValidateMerkleMap( |
| merkle, atoms->mdat_atoms[i], aux_maps, assertion_.alg(), tracker)); |
| } |
| return absl::OkStatus(); |
| } |
| |
| } // namespace credentio |