Public release

GitOrigin-RevId: 17b5cbec4df844eb5a7a2bf9dbb9121d03446f8a
Change-Id: Ieba593e8a374a62e1a4abdea4aa283d03515aa1b
diff --git a/README.md b/README.md
index 7612f3c..3588b6b 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Project Credentio
+# Credentio
 
 C++ libraries to support validation and generation of C2PA Content Credentials
 (https://c2pa.org/).
@@ -11,12 +11,15 @@
 The library supports C2PA provenance extraction and validation for files with
 the following extensions:
 
-| Category        | Extensions                                          |
-| :-------------- | :-------------------------------------------------- |
-| **Image**       | `.avif`, `.dng`, `.gif`, `.heic`, `.heif`, `.jpeg`, |
-:                 : `.jpg`, `.png`, `.tif`, `.tiff`, `.webp`            :
-| **Video/Audio** | `.m4a`, `.mov`, `.mp3`, `.mp4`, `.wav`, `.flac`     |
-| **Document**    | `.pdf`, `.docx`, `.pptx`, `.xlsx`                   |
+<!-- mdformat off(no multiline table cells) -->
+
+Category        | Extensions
+:-------------- | :---------
+**Image**       | `.avif`, `.dng`, `.gif`, `.heic`, `.heif`, `.jpeg`, `.jpg`, `.png`, `.tif`, `.tiff`, `.webp`
+**Video/Audio** | `.avi`, `.m4a`, `.mov`, `.mp3`, `.mp4`, `.wav`, `.flac`
+**Document**    | `.pdf`, `.docx`, `.pptx`, `.xlsx`
+
+<!-- mdformat on -->
 
 ## Prerequisites
 
@@ -37,8 +40,8 @@
 
 ## Quickstart
 
-Project Credentio includes a fast standalone command-line tool for inspecting
-and verifying C2PA metadata embedded in media assets.
+Credentio includes a fast standalone command-line tool for inspecting and
+verifying C2PA metadata embedded in media assets.
 
 #### Building the CLI
 
@@ -58,9 +61,9 @@
   --tsa_trust=/path/to/tsa_trust_anchors.pem
 ```
 
-> **Note on Trust Lists:** Project Credentio does not distribute or provide
-> trust anchor lists. You can obtain the latest official C2PA trust lists from
-> the C2PA organization on GitHub
+> **Note on Trust Lists:** Credentio does not distribute or provide trust anchor
+> lists. You can obtain the latest official C2PA trust lists from the C2PA
+> organization on GitHub
 > ([https://github.com/c2pa-org/conformance-public/tree/main/trust-list](https://github.com/c2pa-org/conformance-public/tree/main/trust-list)).
 
 ## Building & Testing
@@ -79,13 +82,13 @@
 
 ## Support & Releases
 
-Project Credentio recommends that users **live-at-head** (updating to the latest
-commit on the main branch as often as possible). We are actively developing this
+Credentio recommends that users **live-at-head** (updating to the latest commit
+on the main branch as often as possible). We are actively developing this
 project and may introduce breaking changes without notice.
 
 ## License & Disclaimer
 
-Project Credentio is licensed under the terms of the Apache 2.0 License. See
+Credentio is licensed under the terms of the Apache 2.0 License. See
 [LICENSE](LICENSE) for more information.
 
 ### Disclaimer
@@ -96,4 +99,5 @@
 
 ## Contact
 
-For questions, feedback, or inquiries, please contact: `c2pa-core@google.com`.
+For questions, feedback, or inquiries, please contact:
+`credentio-team@google.com`.
diff --git a/bindings/merkle_validator.cc b/bindings/merkle_validator.cc
index 9ec8efe..dc04cbc 100644
--- a/bindings/merkle_validator.cc
+++ b/bindings/merkle_validator.cc
@@ -15,6 +15,7 @@
 
 #include "bindings/merkle_validator.h"
 
+#include <algorithm>
 #include <cstdint>
 #include <memory>
 #include <string>
@@ -128,17 +129,12 @@
           ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(contents));
 
           if (purpose == "merkle") {
-            // Leverage the offset found within the manifest to find first aux
-            // 4 bytes are the version and flag data
-            // 1 byte is the null terminator of the purpose string
-            const uint64_t metadata_header_size = purpose.size() + 4 + 1;
-            if (header.box_size < header.header_size ||
-                header.box_size - header.header_size < metadata_header_size) {
+            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");
             }
-            int64_t remaining_data =
-                header.box_size - header.header_size - metadata_header_size;
+            uint64_t remaining_data = end_of_box - contents.pos();
             ABSL_ASSIGN_OR_RETURN(auto aux_box,
                                   ReadBmffMerkleMap(contents, remaining_data));
 
@@ -179,18 +175,33 @@
     return derived_data.status();
   }
 
-  if (mdat_atom.box_size < mdat_atom.header_size) {
+  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"});
+         .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");
+        "mdat atom box_size is smaller than header_size or exclusion "
+        "adjustment");
   }
 
-  // Calculate and validate the lengths of each leaf.
-  absl::StatusOr<std::vector<int64_t>> leaf_sizes = DeriveMerkleBlockSizes(
-      merkle, mdat_atom.box_size - mdat_atom.header_size);
+  // 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,
@@ -200,7 +211,7 @@
 
   // Based on the above calculations, we ensure the assertion is not malformed,
   // now compute and compare the leaf hashes.
-  int64_t offset = mdat_atom.start + mdat_atom.header_size;
+  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 =
@@ -226,11 +237,16 @@
 
     std::string computed_hash = std::move(leaf_hash.value());
 
+    uint64_t running_row_index = i;
     if (!auxiliary_merkle_maps.empty()) {
-      int64_t running_row_index = i;
       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()) {
-        if (running_row_index % 2 == 1) {
+        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 {
@@ -238,21 +254,19 @@
                                 JoinHashes(**factory, computed_hash, hash));
         }
         running_row_index >>= 1;
+        ++lvl;
       }
     }
 
-    int64_t hashes_index = i >> derived_data->delta_rows;
+    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 at index %d: Expected: %s, Actual: %s",
-               hashes_index, 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();
 }
 
diff --git a/bindings/merkle_validator_test.cc b/bindings/merkle_validator_test.cc
index 993575b..009133e 100644
--- a/bindings/merkle_validator_test.cc
+++ b/bindings/merkle_validator_test.cc
@@ -318,8 +318,7 @@
             .expected_failures =
                 {{.code = FailureStatusCode::kAssertionBmffHashMismatch,
                   .url = "assertion_uri",
-                  .explanation = "merkle map hash mismatch at index 0: "
-                                 "Expected: wrong_hash, Actual: ab"}},
+                  .explanation = "merkle map hash mismatch on leaf: 0"}},
         },
         MerkleValidatorTestCase{
             .name = "FailsAuxiliaryDataBeforeLastMdat",
@@ -372,8 +371,7 @@
             .expected_failures =
                 {{.code = FailureStatusCode::kAssertionBmffHashMismatch,
                   .url = "assertion_uri",
-                  .explanation = "merkle map hash mismatch at index 0: "
-                                 "Expected: wrong_hash, Actual: ab"}},
+                  .explanation = "merkle map hash mismatch on leaf: 0"}},
         },
         MerkleValidatorTestCase{
             .name = "FailsUnsupportedHashAlgorithm",
@@ -434,8 +432,7 @@
             .expected_failures =
                 {{.code = FailureStatusCode::kAssertionBmffHashMismatch,
                   .url = "assertion_uri",
-                  .explanation = "merkle map hash mismatch at index 0: "
-                                 "Expected: wrong_hash, Actual: abcdef"}},
+                  .explanation = "merkle map hash mismatch on leaf: 0"}},
         },
         MerkleValidatorTestCase{
             .name = "FailsBoxSizeTooSmallForMetadataHeaders",
diff --git a/cbor/parse.cc b/cbor/parse.cc
index bb3de64..1893c87 100644
--- a/cbor/parse.cc
+++ b/cbor/parse.cc
@@ -20,7 +20,6 @@
 #include <utility>
 
 #include "absl/status/status.h"
-#include "absl/status/statusor.h"
 #include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
 #include "cbor/cbor.h"
@@ -36,10 +35,6 @@
     return absl::InvalidArgumentError(
         absl::StrCat("CBOR parsing failed: ", error));
   }
-  if (new_position != end) {
-    return absl::InvalidArgumentError(
-        "Trailing bytes after the parsed CBOR item");
-  }
   return std::make_unique<ParseResult>(std::move(item));
 }
 
diff --git a/cbor/parse_test.cc b/cbor/parse_test.cc
index 78aafd5..f620b15 100644
--- a/cbor/parse_test.cc
+++ b/cbor/parse_test.cc
@@ -44,6 +44,20 @@
 using ::testing::IsFalse;
 using ::testing::IsTrue;
 
+#ifndef ASSERT_OK_AND_ASSIGN
+#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y
+#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y)
+
+#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \
+  ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__)
+
+#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id)             \
+  auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \
+  ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id),    \
+              ::absl_testing::IsOk());                        \
+  lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id))
+#endif
+
 TEST(ParseTest, Okay) {
   auto cbor = FromJson(R"json({
       "str": "Google",
@@ -382,11 +396,12 @@
                        HasSubstr("CBOR parsing failed")));
 }
 
-TEST(ParseTest, ErrorTrailingBytes) {
+TEST(ParseTest, TrailingBytesIgnored) {
   auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
-  EXPECT_THAT(cbor::Parse(cbor + "trailing bytes"),
-              StatusIs(absl::StatusCode::kInvalidArgument,
-                       HasSubstr("Trailing bytes")));
+  ASSERT_OK_AND_ASSIGN(auto result, cbor::Parse(cbor + "trailing bytes"));
+  ASSERT_OK_AND_ASSIGN(auto result_map, result->AsMap());
+  EXPECT_THAT(result_map.GetString("vendor"), IsOkAndHolds("Google"));
+  EXPECT_THAT(result_map.GetUint64("schema"), IsOkAndHolds(0));
 }
 
 void CheckMap(const cbor::MapView& map, absl::string_view str_key,
diff --git a/formats/riff/format.cc b/formats/riff/format.cc
index f2c620f..2c41364 100644
--- a/formats/riff/format.cc
+++ b/formats/riff/format.cc
@@ -28,7 +28,7 @@
   return Format::Create(FormatOptions{
       .assessor = std::make_unique<RiffAssessor>(),
       .extractor = std::make_unique<RiffExtractor>(),
-      .mime_types = {"image/webp", "audio/wav"},
+      .mime_types = {"image/webp", "audio/wav", "video/x-msvideo"},
   });
 }
 
diff --git a/tools/BUILD b/tools/BUILD
index 7322fe9..5b96d35 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -23,6 +23,8 @@
     deps = [
         "//crypto:crypto_read_handler",
         "//crypto/default:default_crypto_read_handler",
+        "//formats:core_registry",
+        "//utils:crjson",
         "//utils:media_type",
         "//validator:asset_validator_impl",
         "//validator:result",
@@ -35,7 +37,9 @@
         "@abseil-cpp//absl/status:statusor",
         "@abseil-cpp//absl/strings",
         "@abseil-cpp//absl/strings:string_view",
+        "@nlohmann_json//:json",
         "@protobuf",
         "@riegeli//riegeli/bytes:cfile_reader",
+        "@riegeli//riegeli/bytes:reader",
     ],
 )
diff --git a/tools/asset_validator_main.cc b/tools/asset_validator_main.cc
index 700cd54..8c2aaa8 100644
--- a/tools/asset_validator_main.cc
+++ b/tools/asset_validator_main.cc
@@ -31,8 +31,12 @@
 #include "absl/strings/string_view.h"
 #include "crypto/crypto_read_handler.h"
 #include "crypto/default/default_crypto_read_handler.h"
+#include "formats/core_registry.h"
 #include "google/protobuf/text_format.h"
+#include "nlohmann/json_fwd.hpp"
 #include "riegeli/bytes/cfile_reader.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/crjson.h"
 #include "utils/media_type.h"
 #include "validator/asset_validator_impl.h"
 #include "validator/result.h"
@@ -48,6 +52,9 @@
 ABSL_FLAG(std::string, tsa_trust, "",
           "Path to PEM file containing TSA trust anchors. If not provided, "
           "then the validator will default to skip TSA trust checks.");
+ABSL_FLAG(std::string, output_format, "crjson",
+          "The format to output the validation results in. [txtpb, crjson] "
+          "(Default: crjson)");
 
 namespace {
 
@@ -62,13 +69,54 @@
   return buffer.str();
 }
 
+absl::Status PrintResultInTxtpb(const credentio::ValidationResult& result) {
+  std::string text_format;
+  if (google::protobuf::TextFormat::PrintToString(result.proto(),
+                                                  &text_format)) {
+    std::cout << "Validation Result:\n" << text_format << "\n";
+  } else {
+    return absl::InternalError("Failed to convert result proto to text format");
+  }
+  return absl::OkStatus();
+}
+
+absl::Status PrintResultInCrJson(
+    riegeli::Reader& reader, std::optional<absl::string_view> media_type_opt,
+    const credentio::ValidationResult& result) {
+  if (!media_type_opt.has_value()) {
+    return absl::InvalidArgumentError("Media type is required");
+  }
+
+  auto registry = credentio::CreateCoreFormatRegistry();
+  auto format = registry->GetFormat(*media_type_opt);
+  if (!format.ok()) {
+    return format.status();
+  }
+  if (!reader.Seek(0) || reader.pos() != 0) {
+    return absl::DataLossError("Failed to seek to start of reader");
+  }
+  auto manifest_store = (*format)->extractor()->ExtractManifestStore(reader);
+  if (!manifest_store.ok()) {
+    return manifest_store.status();
+  }
+
+  absl::StatusOr<nlohmann::json> crjson =
+      credentio::ConvertToCrJson(*manifest_store, result.proto());
+  if (!crjson.ok()) {
+    return crjson.status();
+  }
+  std::cout << "Validation Result (crjson):\n" << crjson->dump(2) << "\n";
+  return absl::OkStatus();
+}
+
 }  // namespace
 
 int main(int argc, char* argv[]) {
   absl::SetProgramUsageMessage(
       "Validates C2PA asset files and prints validation results.\n"
       "Usage:\n  c2pa_validate --asset=<path_to_asset> "
-      "[--claim_signer_trust=<pem_path>] [--tsa_trust=<pem_path>]");
+      "[--claim_signer_trust=<pem_path>] [--tsa_trust=<pem_path>] "
+      "[--output_format=<txtpb, crjson>]");
   absl::InitializeLog();
   absl::ParseCommandLine(argc, argv);
 
@@ -78,6 +126,12 @@
     return 1;
   }
 
+  const std::string output_format = absl::GetFlag(FLAGS_output_format);
+  if (output_format != "txtpb" && output_format != "crjson") {
+    std::cerr << "Error: --output_format must be txtpb or crjson.\n";
+    return 1;
+  }
+
   const std::string claim_signer_trust_path =
       absl::GetFlag(FLAGS_claim_signer_trust);
   const std::string tsa_trust_path = absl::GetFlag(FLAGS_tsa_trust);
@@ -152,12 +206,19 @@
   }
 
   std::cout << "Validation successful!\n";
-  std::string text_format;
-  if (google::protobuf::TextFormat::PrintToString((*result)->proto(),
-                                                  &text_format)) {
-    std::cout << "Validation Result:\n" << text_format << "\n";
-  } else {
-    std::cerr << "Failed to convert result proto to text format.\n";
+
+  if (output_format == "crjson") {
+    absl::Status status = PrintResultInCrJson(reader, media_type_opt, **result);
+    if (status.ok()) {
+      return 0;
+    }
+    std::cerr << "Failed to print result in crjson, reverting to txtpb: "
+              << status << "\n";
+  }
+
+  absl::Status status = PrintResultInTxtpb(**result);
+  if (!status.ok()) {
+    std::cerr << "Failed to print result in txtpb: " << status << "\n";
     return 1;
   }
 
diff --git a/utils/BUILD b/utils/BUILD
index 0330b49..a8d25b0 100644
--- a/utils/BUILD
+++ b/utils/BUILD
@@ -288,3 +288,86 @@
         "@googletest//:gtest_main",
     ],
 )
+
+cc_library(
+    name = "crjson_utils",
+    srcs = ["crjson_utils.cc"],
+    hdrs = ["crjson_utils.h"],
+    deps = [
+        "//cbor",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@libcppbor",
+        "@nlohmann_json//:json",
+    ],
+)
+
+cc_library(
+    name = "crjson",
+    srcs = ["crjson.cc"],
+    hdrs = ["crjson.h"],
+    deps = [
+        ":crjson_utils",
+        "//cbor",
+        "//cbor:parse",
+        "//constants:labels",
+        "//cose:sig_structure",
+        "//cose:simple_cms_parser",
+        "//crypto:algorithms",
+        "//jumbf:box",
+        "//jumbf:convert_json",
+        "//jumbf:parse",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//tsp:timestamp_verifier",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@boringssl//:crypto",
+        "@nlohmann_json//:json",
+        "@protobuf//:protobuf_lite",
+    ],
+)
+
+cc_test(
+    name = "crjson_utils_test",
+    srcs = ["crjson_utils_test.cc"],
+    deps = [
+        ":crjson_utils",
+        "//cbor",
+        "@googletest//:gtest_main",
+        "@libcppbor",
+        "@nlohmann_json//:json",
+    ],
+)
+
+cc_test(
+    name = "crjson_test",
+    srcs = ["crjson_test.cc"],
+    deps = [
+        ":crjson",
+        "//constants:labels",
+        "//jumbf:constants",
+        "//jumbf:test_utils",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@libcppbor",
+        "@nlohmann_json//:json",
+    ],
+)
diff --git a/utils/crjson.cc b/utils/crjson.cc
new file mode 100644
index 0000000..1b2c24c
--- /dev/null
+++ b/utils/crjson.cc
@@ -0,0 +1,813 @@
+// 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 "utils/crjson.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/numbers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/clock.h"
+#include "absl/time/time.h"
+#include "cbor/cbor.h"
+#include "cbor/parse.h"
+#include "constants/labels.h"
+#include "cose/sig_structure.h"
+#include "cose/simple_cms_parser.h"
+#include "crypto/algorithms.h"
+#include "google/protobuf/repeated_ptr_field.h"
+#include "jumbf/box.h"
+#include "jumbf/convert_json.h"  // IWYU pragma: keep
+#include "jumbf/parse.h"
+#include "nlohmann/json.hpp"
+#include "nlohmann/json_fwd.hpp"
+#include "openssl/asn1.h"
+#include "openssl/bn.h"
+#include "openssl/mem.h"
+#include "openssl/obj.h"
+#include "openssl/x509.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "tsp/timestamp_verifier.h"
+#include "utils/crjson_utils.h"
+
+namespace credentio {
+
+namespace {
+
+using Json = ::nlohmann::json;
+
+// =============================================================================
+// JUMBF/CBOR Helpers
+// =============================================================================
+
+template <typename T>
+absl::StatusOr<const T&> EnsureAndGetSingleBox(const jumbf::SuperBox& box) {
+  if (box.contents.size() != 1) {
+    return absl::InvalidArgumentError(
+        absl::StrCat(box.description.label.value_or("unspecified"),
+                     " box must contain exactly one box"));
+  }
+  if (!box.contents[0].Holds<T>()) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        box.description.label.value_or("unspecified"),
+        " must contained box of index: ", box.contents[0].payload.index()));
+  }
+  return box.contents[0].Get<T>();
+}
+
+template <typename T>
+struct ParsedCbor {
+  std::unique_ptr<cbor::ParseResult> parse_result;
+  T cbor_view;
+};
+
+absl::StatusOr<ParsedCbor<cbor::MapView>> ParseCborMap(absl::string_view cbor) {
+  auto parse_result = cbor::Parse(cbor);
+  if (!parse_result.ok()) {
+    return parse_result.status();
+  }
+  auto cbor_map = (*parse_result)->AsMap();
+  if (!cbor_map.ok()) {
+    return cbor_map.status();
+  }
+  return ParsedCbor<cbor::MapView>{std::move(*parse_result), *cbor_map};
+}
+
+absl::StatusOr<ParsedCbor<cbor::MapView>> GetCborMap(
+    const jumbf::SuperBox& box) {
+  auto cbor_box = EnsureAndGetSingleBox<jumbf::CborBox>(box);
+  if (!cbor_box.ok()) {
+    return cbor_box.status();
+  }
+  return ParseCborMap(cbor_box->payload);
+}
+
+enum class ManifestType {
+  kUnknown,
+  kStandard,
+  kUpdate,
+  kCompressed,
+  kTimestamp,
+};
+
+ManifestType GetManifestType(const jumbf::SuperBox& superbox) {
+  const auto& uuid = superbox.description.type_uuid;
+  if (uuid == kStandardManifestUuid) {
+    return ManifestType::kStandard;
+  }
+  if (uuid == kUpdateManifestUuid) {
+    return ManifestType::kUpdate;
+  }
+  if (uuid == kCompressedManifestUuid) {
+    return ManifestType::kCompressed;
+  }
+  if (uuid == kTimestampManifestUuid) {
+    return ManifestType::kTimestamp;
+  }
+  return ManifestType::kUnknown;
+}
+
+// =============================================================================
+// Generic Array Converters
+// =============================================================================
+
+Json ConvertArrayOfStrings(uint32_t _, const cbor::ItemView& item) {
+  auto val = item.GetString();
+  if (!val.ok()) {
+    return Json({{"_error", val.status().message()}});
+  }
+  return Json(*val);
+}
+
+// =============================================================================
+// Assertion Converters
+// =============================================================================
+
+Json ConvertThumbnailAssertion(const jumbf::SuperBox& box) {
+  Json crjson = Json::object();
+  absl::string_view label = box.description.label.value_or("");
+
+  if (absl::StartsWith(label, "c2pa.thumbnail.claim")) {
+    crjson["thumbnailType"] = 0;
+  } else if (absl::StartsWith(label, "c2pa.thumbnail.ingredient")) {
+    crjson["thumbnailType"] = 1;
+  }
+
+  size_t prefix_len = absl::StartsWith(label, "c2pa.thumbnail.claim") ? 20 : 25;
+  size_t last_dot = label.rfind('.');
+  if (last_dot != absl::string_view::npos && last_dot >= prefix_len) {
+    absl::string_view mime_type = label.substr(last_dot + 1);
+    size_t suffix_start = mime_type.find("__");
+    if (suffix_start != absl::string_view::npos) {
+      mime_type = mime_type.substr(0, suffix_start);
+    }
+    crjson["mimeType"] = std::string(mime_type);
+  }
+
+  return crjson;
+}
+
+Json ConvertMetadataAssertion(const jumbf::SuperBox& box) {
+  Json crjson = Json::object();
+
+  auto json_box = EnsureAndGetSingleBox<jumbf::JsonBox>(box);
+  if (json_box.ok()) {
+    Json j;
+    jumbf::to_json(j, *json_box);
+    if (j.contains("json")) {
+      return j["json"];
+    } else {
+      crjson["_error"] = "Failed to convert JSON box to JSON";
+      return crjson;
+    }
+  } else {
+    crjson["_error"] = "Metadata assertion is not a JSON box";
+    return crjson;
+  }
+}
+
+bool LabelMatches(absl::string_view label, absl::string_view base_label) {
+  if (!absl::StartsWith(label, base_label)) {
+    return false;
+  }
+  if (label.size() == base_label.size()) {
+    return true;
+  }
+  if (label.size() > base_label.size() + 2 &&
+      label.substr(base_label.size(), 2) == "__") {
+    absl::string_view number = label.substr(base_label.size() + 2);
+    uint64_t val;
+    return absl::SimpleAtoi(number, &val);
+  }
+  return false;
+}
+
+// This is an allowlist of standard assertions that are supported by C2PA v2.4.
+bool IsAllowlistedStandardAssertion(absl::string_view label) {
+  return LabelMatches(label, kDataHashAssertionLabel) ||
+         LabelMatches(label, kDataHashAssertionPartLabel) ||
+         LabelMatches(label, kBmffBasedHashAssertionV2Label) ||
+         LabelMatches(label, kBmffBasedHashAssertionV3Label) ||
+         LabelMatches(label, kBmffBasedHashAssertionV2PartLabel) ||
+         LabelMatches(label, kBmffBasedHashAssertionV3PartLabel) ||
+         LabelMatches(label, kBoxesHashAssertionLabel) ||
+         LabelMatches(label, kBoxesHashAssertionPartLabel) ||
+         LabelMatches(label, kMultiAssetHashAssertionLabel) ||
+         LabelMatches(label, kCollectionDataHashAssertionLabel) ||
+         LabelMatches(label, kSoftBindingAssertionLabel) ||
+         LabelMatches(label, kActionsAssertionV1Label) ||
+         LabelMatches(label, kActionsAssertionV2Label) ||
+         LabelMatches(label, kIngredientAssertionV1Label) ||
+         LabelMatches(label, kIngredientAssertionV2Label) ||
+         LabelMatches(label, kIngredientAssertionV3Label) ||
+         LabelMatches(label, "font.info") ||
+         LabelMatches(label, "c2pa.font.info") ||
+         LabelMatches(label, "c2pa.cloud-data") ||
+         LabelMatches(label, "c2pa.session-keys") ||
+         LabelMatches(label, "c2pa.ai-disclosure") ||
+         LabelMatches(label, "c2pa.certificate-status") ||
+         LabelMatches(label, "c2pa.asset-ref") ||
+         LabelMatches(label, "c2pa.depthmap") ||
+         LabelMatches(label, "c2pa.repository-receipt") ||
+         LabelMatches(label, "c2pa.embedded_data") ||
+         LabelMatches(label, "c2pa.environmental-sustainability") ||
+         LabelMatches(label, "c2pa.time-stamp");
+}
+
+Json ConvertAssertionStore(const jumbf::SuperBox& box) {
+  Json crjson = Json::object();
+
+  for (const jumbf::ContentBox& content_box : box.contents) {
+    if (!content_box.Holds<jumbf::SuperBox>()) {
+      crjson["_error"] = "Assertion store contains non-SuperBox content";
+      continue;
+    }
+
+    const jumbf::SuperBox& child_box = content_box.Get<jumbf::SuperBox>();
+    absl::string_view label = child_box.description.label.value_or("");
+    if (absl::StartsWith(label, "c2pa.thumbnail.claim") ||
+        absl::StartsWith(label, "c2pa.thumbnail.ingredient")) {
+      crjson[label] = ConvertThumbnailAssertion(child_box);
+    } else if (LabelMatches(label, "c2pa.metadata")) {
+      crjson[label] = ConvertMetadataAssertion(child_box);
+    } else if (IsAllowlistedStandardAssertion(label)) {
+      if (auto cbor_box = EnsureAndGetSingleBox<jumbf::CborBox>(child_box);
+          cbor_box.ok()) {
+        Json j;
+        jumbf::to_json(j, *cbor_box);
+        if (j.contains("cbor")) {
+          crjson[label] = j["cbor"];
+        } else {
+          crjson[label] = Json::object();
+          crjson[label]["_error"] = "Failed to convert CBOR to JSON";
+        }
+      } else if (auto json_box =
+                     EnsureAndGetSingleBox<jumbf::JsonBox>(child_box);
+                 json_box.ok()) {
+        Json j;
+        jumbf::to_json(j, *json_box);
+        if (j.contains("json")) {
+          crjson[label] = j["json"];
+        } else {
+          crjson[label] = Json::object();
+          crjson[label]["_error"] = "Failed to convert JSON box to JSON";
+        }
+      }
+    } else {
+      crjson[label] = Json::object();
+    }
+  }
+
+  return crjson;
+}
+
+// =============================================================================
+// Claim Helpers
+// =============================================================================
+
+// Converts a hashed URI map to a crJSON object.
+//  - CBOR CDDL:
+//  https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedded
+Json ConvertHashedUri(const cbor::MapView& uri_map) {
+  Json crjson = Json::object();
+  RecordString(uri_map, "url", true, crjson);
+  RecordString(uri_map, "alg", false, crjson);
+  RecordByteString(uri_map, "hash", true, crjson);
+  return crjson;
+}
+
+Json ConvertClaimGeneratorInfo(const cbor::MapView& map) {
+  Json crjson = Json::object();
+  RecordString(map, "name", true, crjson);
+  RecordString(map, "version", false, crjson);
+  RecordMap(map, "icon", false, ConvertHashedUri, crjson);
+  RecordString(map, "operating_system", false, crjson);
+  return crjson;
+}
+
+Json ConvertRating(const cbor::MapView& map) {
+  Json rating = Json::object();
+  RecordString(map, "value", true, rating);
+  RecordString(map, "code", false, rating);
+  RecordString(map, "explanation", false, rating);
+  return rating;
+}
+
+Json ConvertSourceMap(const cbor::MapView& map) {
+  Json crjson = Json::object();
+  RecordString(map, "type", true, crjson);
+  RecordString(map, "details", false, crjson);
+  return crjson;
+}
+
+Json ConvertLocalizations(const cbor::MapView& map) {
+  Json crjson = Json::object();
+  // Dynamic fields...
+  return crjson;
+}
+
+Json ConvertRegionOfInterest(const cbor::MapView& map) {
+  Json crjson = Json::object();
+  // No fields in the spec.
+  return crjson;
+}
+
+Json ConvertMetadata(const cbor::MapView& map) {
+  Json crjson = Json::object();
+  RecordString(map, "dateTime", false, crjson);
+  RecordArrayOfMaps(map, "rating", false, ConvertRating, crjson);
+  RecordMap(map, "reference", false, ConvertHashedUri, crjson);
+  RecordMap(map, "dataSource", false, ConvertSourceMap, crjson);
+  RecordArrayOfMaps(map, "localizations", false, ConvertLocalizations, crjson);
+  RecordMap(map, "regionOfInterest", false, ConvertRegionOfInterest, crjson);
+  return crjson;
+}
+
+// =============================================================================
+// Claim Converters
+// =============================================================================
+
+Json ConvertClaimV2(const jumbf::SuperBox& box) {
+  Json crjson = Json::object();
+
+  auto parsed_cbor = GetCborMap(box);
+  if (!parsed_cbor.ok()) {
+    crjson["_error"] = parsed_cbor.status().message();
+    return crjson;
+  }
+
+  RecordString(parsed_cbor->cbor_view, "instanceID", true, crjson);
+  RecordMap(parsed_cbor->cbor_view, "claim_generator_info", true,
+            ConvertClaimGeneratorInfo, crjson);
+  RecordString(parsed_cbor->cbor_view, "signature", true, crjson);
+  RecordArrayOfMaps(parsed_cbor->cbor_view, "created_assertions", true,
+                    ConvertHashedUri, crjson);
+  RecordArrayOfMaps(parsed_cbor->cbor_view, "gathered_assertions", true,
+                    ConvertHashedUri, crjson);
+  RecordString(parsed_cbor->cbor_view, "dc:title", false, crjson);
+  RecordArray(parsed_cbor->cbor_view, "redacted_assertions", true,
+              ConvertArrayOfStrings, crjson);
+  RecordString(parsed_cbor->cbor_view, "alg", false, crjson);
+  RecordString(parsed_cbor->cbor_view, "alg_soft", false, crjson);
+  RecordString(parsed_cbor->cbor_view, "specVersion", false, crjson);
+  RecordMap(parsed_cbor->cbor_view, "metadata", false, ConvertMetadata, crjson);
+
+  return crjson;
+}
+
+// =============================================================================
+// Signature Helpers
+// =============================================================================
+
+std::string SigningAlgorithmToString(SigningAlgorithm alg) {
+  switch (alg) {
+    case SigningAlgorithm::kEs256:
+      return "ES256";
+    case SigningAlgorithm::kEs384:
+      return "ES384";
+    case SigningAlgorithm::kEs512:
+      return "ES512";
+    case SigningAlgorithm::kPs256:
+      return "PS256";
+    case SigningAlgorithm::kPs384:
+      return "PS384";
+    case SigningAlgorithm::kPs512:
+      return "PS512";
+    case SigningAlgorithm::kEdDsa:
+      return "Ed25519";
+  }
+  return "Unknown";
+}
+
+Json ConvertDNParsed(X509_NAME* name) {
+  Json dn_map = Json::object();
+  if (!name) return dn_map;
+  int count = X509_NAME_entry_count(name);
+  for (int i = 0; i < count; ++i) {
+    X509_NAME_ENTRY* entry = X509_NAME_get_entry(name, i);
+    ASN1_OBJECT* obj = X509_NAME_ENTRY_get_object(entry);
+    ASN1_STRING* str = X509_NAME_ENTRY_get_data(entry);
+
+    int nid = OBJ_obj2nid(obj);
+    const char* short_name = OBJ_nid2sn(nid);
+
+    unsigned char* utf8_str = nullptr;
+    int len = ASN1_STRING_to_UTF8(&utf8_str, str);
+    if (len >= 0) {
+      if (short_name) {
+        dn_map[short_name] =
+            std::string(reinterpret_cast<char*>(utf8_str), len);
+      } else {
+        char oid_buf[128];
+        OBJ_obj2txt(oid_buf, sizeof(oid_buf), obj, 1);
+        dn_map[oid_buf] = std::string(reinterpret_cast<char*>(utf8_str), len);
+      }
+      OPENSSL_free(utf8_str);
+    }
+  }
+  return dn_map;
+}
+
+std::string ConvertASN1TimeToISO8601(const ASN1_TIME* t) {
+  if (!t) return "";
+
+  int64_t posix_time;
+  if (ASN1_TIME_to_posix(t, &posix_time) == 0) {
+    return "";
+  }
+  return absl::FormatTime(absl::RFC3339_full, absl::FromUnixSeconds(posix_time),
+                          absl::UTCTimeZone());
+}
+
+Json ConvertCertificateInfo(absl::string_view certificate_der) {
+  Json cert_info = Json::object();
+
+  const uint8_t* p = reinterpret_cast<const uint8_t*>(certificate_der.data());
+  X509* cert = d2i_X509(nullptr, &p, certificate_der.size());
+  if (!cert) {
+    cert_info["_error"] = "Failed to parse certificate";
+    return cert_info;
+  }
+
+  ASN1_INTEGER* serial = X509_get_serialNumber(cert);
+  BIGNUM* bn = ASN1_INTEGER_to_BN(serial, nullptr);
+  if (bn) {
+    char* hex = BN_bn2hex(bn);
+    if (hex) {
+      cert_info["serialNumber"] = std::string(hex);
+      OPENSSL_free(hex);
+    }
+    BN_free(bn);
+  }
+
+  cert_info["subject"] = ConvertDNParsed(X509_get_subject_name(cert));
+  cert_info["issuer"] = ConvertDNParsed(X509_get_issuer_name(cert));
+
+  Json validity = Json::object();
+  validity["notBefore"] = ConvertASN1TimeToISO8601(X509_get0_notBefore(cert));
+  validity["notAfter"] = ConvertASN1TimeToISO8601(X509_get0_notAfter(cert));
+  cert_info["validity"] = validity;
+
+  X509_free(cert);
+  return cert_info;
+}
+
+// =============================================================================
+// Signature Converter
+// =============================================================================
+
+Json ConvertClaimSignature(const jumbf::SuperBox& box) {
+  Json crjson = Json::object();
+
+  auto cbor_box = EnsureAndGetSingleBox<jumbf::CborBox>(box);
+  if (!cbor_box.ok()) {
+    return crjson;
+  }
+
+  auto cose_sign1 = DecodeCoseSign1TaggedStructure(cbor_box->payload);
+  if (!cose_sign1.ok()) {
+    return crjson;
+  }
+
+  auto protected_header = DecodeProtectedHeader(cose_sign1->protected_header);
+  if (protected_header.ok()) {
+    crjson["algorithm"] = SigningAlgorithmToString(protected_header->alg);
+  }
+
+  std::vector<std::string> cert_chain;
+  if (!cose_sign1->unprotected_header.certificate_chain.empty()) {
+    cert_chain = cose_sign1->unprotected_header.certificate_chain;
+  } else if (protected_header.ok()) {
+    cert_chain = protected_header->certificate_chain;
+  }
+
+  if (!cert_chain.empty()) {
+    crjson["certificateInfo"] = ConvertCertificateInfo(cert_chain[0]);
+  }
+
+  // Handle Timestamp
+  auto timestamp_container = cose_sign1->unprotected_header.sig_tst2;
+  if (!timestamp_container.has_value()) {
+    timestamp_container = cose_sign1->unprotected_header.sig_tst;
+  }
+
+  if (timestamp_container.has_value() &&
+      !timestamp_container->tst_tokens.empty()) {
+    if (timestamp_container->tst_tokens.size() == 1) {
+      absl::string_view timestamp_token =
+          timestamp_container->tst_tokens[0].val;
+      TimestampVerifier verifier(nullptr);  // Skip trust checks
+      auto parsed_token = SimpleParsedTimestampToken::Create(timestamp_token);
+      if (parsed_token.ok()) {
+        auto verified_timestamp = verifier.VerifyTimestampToken(**parsed_token);
+        if (verified_timestamp.ok()) {
+          Json ts_info = Json::object();
+          ts_info["timestamp"] = absl::FormatTime(
+              absl::RFC3339_full, verified_timestamp->asserted_time(),
+              absl::UTCTimeZone());
+          ts_info["certificateInfo"] =
+              ConvertCertificateInfo(verified_timestamp->tsa_certificate());
+          crjson["timeStampInfo"] = ts_info;
+        }
+      }
+    }
+  }
+
+  return crjson;
+}
+
+Json ConvertValidationStatusSetToJson(
+    const ValidationStatusSet& validation,
+    std::optional<absl::string_view> spec_version = std::nullopt,
+    std::optional<absl::string_view> trust_list_uri = std::nullopt) {
+  Json v = Json::object();
+  v["success"] = Json::array();
+  v["informational"] = Json::array();
+  v["failure"] = Json::array();
+  if (spec_version.has_value()) {
+    v["specVersion"] = *spec_version;
+  }
+  if (trust_list_uri.has_value()) {
+    v["trustListURI"] = *trust_list_uri;
+  }
+  v["validationTime"] =
+      absl::FormatTime(absl::RFC3339_full, absl::Now(), absl::UTCTimeZone());
+
+  for (const auto& success : validation.successes()) {
+    Json s = Json::object();
+    s["code"] = success.code();
+    if (!success.url().empty()) {
+      s["url"] = success.url();
+    }
+    if (!success.explanation().empty()) {
+      s["explanation"] = success.explanation();
+    }
+    v["success"].push_back(s);
+  }
+  for (const auto& informational : validation.informationals()) {
+    Json i = Json::object();
+    i["code"] = informational.code();
+    if (!informational.url().empty()) {
+      i["url"] = informational.url();
+    }
+    if (!informational.explanation().empty()) {
+      i["explanation"] = informational.explanation();
+    }
+    v["informational"].push_back(i);
+  }
+  for (const auto& failure : validation.failures()) {
+    Json f = Json::object();
+    f["code"] = failure.code();
+    if (!failure.url().empty()) {
+      f["url"] = failure.url();
+    }
+    if (!failure.explanation().empty()) {
+      f["explanation"] = failure.explanation();
+    }
+    v["failure"].push_back(f);
+  }
+  return v;
+}
+
+// =============================================================================
+// Top-Level Converters
+// =============================================================================
+
+Json ConvertManifestToCrJson(
+    const jumbf::SuperBox& manifest_box,
+    const ValidationStatusSet* validation_status = nullptr,
+    const google::protobuf::RepeatedPtrField<IngredientDeltaValidationResult>*
+        ingredient_deltas = nullptr,
+    std::optional<absl::string_view> spec_version = std::nullopt,
+    std::optional<absl::string_view> trust_list_uri = std::nullopt) {
+  Json crjson = Json::object();
+  crjson["label"] = manifest_box.description.label.value_or("");
+  switch (GetManifestType(manifest_box)) {
+    case ManifestType::kStandard:
+      crjson["isUpdateManifest"] = false;
+      crjson["isCompressedManifest"] = false;
+      break;
+    case ManifestType::kUpdate:
+      crjson["isUpdateManifest"] = true;
+      crjson["isCompressedManifest"] = false;
+      break;
+    case ManifestType::kCompressed:
+      crjson["isCompressedManifest"] = true;
+      crjson["_error"] = "Compressed manifests are not supported";
+      // Compressed manifest are not supported, so mark an error and return.
+      return crjson;
+    default:
+      crjson["_error"] = "Unknown manifest type";
+      // Unknown manifest type, so mark an error and return.
+      return crjson;
+  }
+  crjson["assertions"] = Json::object();
+  crjson["signature"] = Json::object();
+
+  if (validation_status != nullptr) {
+    crjson["validationResults"] = ConvertValidationStatusSetToJson(
+        *validation_status, spec_version, trust_list_uri);
+  } else {
+    crjson["validationResults"] = Json::object();
+    crjson["validationResults"]["success"] = Json::array();
+    crjson["validationResults"]["informational"] = Json::array();
+    crjson["validationResults"]["failure"] = Json::array();
+    if (spec_version.has_value()) {
+      crjson["validationResults"]["specVersion"] = *spec_version;
+    }
+    if (trust_list_uri.has_value()) {
+      crjson["validationResults"]["trustListURI"] = *trust_list_uri;
+    }
+    crjson["validationResults"]["validationTime"] =
+        absl::FormatTime(absl::RFC3339_full, absl::Now(), absl::UTCTimeZone());
+  }
+
+  if (ingredient_deltas != nullptr && !ingredient_deltas->empty()) {
+    crjson["ingredientDeltas"] = Json::array();
+    for (const auto& delta : *ingredient_deltas) {
+      Json d = Json::object();
+      d["ingredientAssertionURI"] = delta.ingredient_assertion_uri();
+      // Ingredient deltas should not have spec version or trust list URI.
+      d["validationDeltas"] =
+          ConvertValidationStatusSetToJson(delta.validation_deltas());
+      crjson["ingredientDeltas"].push_back(d);
+    }
+  }
+
+  for (const jumbf::ContentBox& content_box : manifest_box.contents) {
+    if (!content_box.Holds<jumbf::SuperBox>()) {
+      crjson["_error"] = "Manifest contains non-SuperBox content";
+      continue;
+    }
+
+    const jumbf::SuperBox& child_box = content_box.Get<jumbf::SuperBox>();
+    if (child_box.description.label.value_or("") == kAssertionStoreLabel) {
+      crjson["assertions"] = ConvertAssertionStore(child_box);
+    } else if (child_box.description.label.value_or("") == kClaimV2Label) {
+      crjson["claim.v2"] = ConvertClaimV2(child_box);
+    } else if (child_box.description.label.value_or("") ==
+               kClaimSignatureLabel) {
+      crjson["signature"] = ConvertClaimSignature(child_box);
+    } else if (child_box.description.label.value_or("") == "c2pa.databoxes") {
+      // Deprecated, ignore it.
+    } else {
+      // Ignore unhandled top-level boxes in manifest.
+    }
+  }
+
+  return crjson;
+}
+
+absl::flat_hash_map<std::string, ValidationStatusSet> IndexResults(
+    const ValidationResultProto* absl_nullable validation_result) {
+  absl::flat_hash_map<std::string, ValidationStatusSet> manifests;
+  if (validation_result != nullptr) {
+    if (validation_result->has_active_manifest()) {
+      manifests[validation_result->active_manifest().label()] =
+          validation_result->active_manifest().validation();
+    }
+    for (const auto& ingredient : validation_result->ingredient_manifests()) {
+      manifests[ingredient.label()] = ingredient.validation();
+    }
+  }
+  return manifests;
+}
+
+absl::StatusOr<Json> ConvertManifestStoreToCrJsonInternal(
+    absl::string_view raw_manifest_store,
+    const ValidationResultProto* absl_nullable validation_result,
+    const IngredientValidationResultProto* absl_nullable
+        ingredient_validation_result) {
+  auto indexed_validation_results = IndexResults(validation_result);
+
+  // Get the spec version and trust list URI from the validation results.
+  std::optional<absl::string_view> spec_version;
+  std::optional<absl::string_view> trust_list_uri;
+  if (validation_result != nullptr) {
+    if (validation_result->has_spec_version()) {
+      spec_version = validation_result->spec_version();
+    }
+    if (validation_result->has_trust_list_uri()) {
+      trust_list_uri = validation_result->trust_list_uri();
+    }
+  } else if (ingredient_validation_result != nullptr) {
+    if (ingredient_validation_result->has_ingredient_validation_results()) {
+      if (ingredient_validation_result->ingredient_validation_results()
+              .has_spec_version()) {
+        spec_version =
+            ingredient_validation_result->ingredient_validation_results()
+                .spec_version();
+      }
+      if (ingredient_validation_result->ingredient_validation_results()
+              .has_trust_list_uri()) {
+        trust_list_uri =
+            ingredient_validation_result->ingredient_validation_results()
+                .trust_list_uri();
+      }
+    }
+  }
+
+  auto manifest_store_box =
+      jumbf::ConsumeSuperBox(&raw_manifest_store, /*recursion_limit=*/-1);
+  if (!manifest_store_box.ok()) {
+    return manifest_store_box.status();
+  }
+
+  // Build the crJSON.
+  Json crjson = Json::object();
+
+  // Add the context.
+  crjson["@context"] =
+      Json::array({"https://c2pa.org/crjson/crJSON.schema.json"});
+
+  // Add the manifests.
+  crjson["manifests"] = Json::array();
+  bool is_active_manifest = true;
+  for (auto it = manifest_store_box->contents.rbegin();
+       it != manifest_store_box->contents.rend(); ++it) {
+    const jumbf::ContentBox& content_box = *it;
+    if (!content_box.Holds<jumbf::SuperBox>()) {
+      crjson["_error"] = "Manifest store contains non-SuperBox content";
+      continue;
+    }
+
+    auto manifest_box = content_box.Get<jumbf::SuperBox>();
+    auto results = indexed_validation_results.find(
+        manifest_box.description.label.value_or(""));
+
+    const ValidationStatusSet* validation_status = nullptr;
+    if (results != indexed_validation_results.end()) {
+      validation_status = &results->second;
+    } else if (is_active_manifest && ingredient_validation_result != nullptr &&
+               ingredient_validation_result
+                   ->has_ingredient_validation_results()) {
+      validation_status =
+          &ingredient_validation_result->ingredient_validation_results()
+               .active_manifest();
+    }
+
+    crjson["manifests"].push_back(ConvertManifestToCrJson(
+        manifest_box, validation_status,
+        /*ingredient_deltas=*/nullptr, spec_version, trust_list_uri));
+
+    is_active_manifest = false;  // Only first one is active
+  }
+
+  // JSON generator info.
+  crjson["jsonGenerator"]["name"] = "Google C2PA Toolkit";
+  crjson["jsonGenerator"]["version"] = "0.0.1";
+
+  return crjson;
+}
+
+}  // namespace
+
+absl::StatusOr<Json> ConvertToCrJsonWithoutValidationResults(
+    absl::string_view raw_manifest_store) {
+  return ConvertManifestStoreToCrJsonInternal(raw_manifest_store, nullptr,
+                                              nullptr);
+}
+
+absl::StatusOr<Json> ConvertToCrJson(
+    absl::string_view raw_manifest_store,
+    const ValidationResultProto& validation_result) {
+  return ConvertManifestStoreToCrJsonInternal(raw_manifest_store,
+                                              &validation_result, nullptr);
+}
+
+absl::StatusOr<Json> ConvertToCrJson(
+    absl::string_view raw_manifest_store,
+    const IngredientValidationResultProto& ingredient_validation_result) {
+  return ConvertManifestStoreToCrJsonInternal(raw_manifest_store, nullptr,
+                                              &ingredient_validation_result);
+}
+
+}  // namespace credentio
diff --git a/utils/crjson.h b/utils/crjson.h
new file mode 100644
index 0000000..7665cd0
--- /dev/null
+++ b/utils/crjson.h
@@ -0,0 +1,44 @@
+// 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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_UTILS_CRJSON_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_CRJSON_H_
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "nlohmann/json.hpp"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/validation_result.pb.h"
+
+namespace credentio {
+
+// Convert a raw manifest store to crJSON without validation results.
+absl::StatusOr<::nlohmann::json> ConvertToCrJsonWithoutValidationResults(
+    absl::string_view raw_manifest_store);
+
+// Convert a raw manifest store to crJSON and merge validation results.
+absl::StatusOr<::nlohmann::json> ConvertToCrJson(
+    absl::string_view raw_manifest_store,
+    const ValidationResultProto& validation_result);
+
+// Convert a raw manifest store to crJSON and merge ingredient validation
+// results.
+absl::StatusOr<::nlohmann::json> ConvertToCrJson(
+    absl::string_view raw_manifest_store,
+    const IngredientValidationResultProto& ingredient_validation_result);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_CRJSON_H_
diff --git a/utils/crjson_test.cc b/utils/crjson_test.cc
new file mode 100644
index 0000000..f21b17c
--- /dev/null
+++ b/utils/crjson_test.cc
@@ -0,0 +1,357 @@
+// 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 "utils/crjson.h"
+
+#include <cstdint>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "cppbor/cppbor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/constants.h"
+#include "jumbf/test_utils.h"
+#include "nlohmann/json.hpp"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+
+TEST(ConvertManifestStoreToCrJsonTest, ByteStringsAreBase64Encoded) {
+  // 1. Construct a CBOR map containing a byte string.
+  cppbor::Map map;
+  map.add("hash_value", cppbor::Bstr("raw_bytes_here"));
+  std::vector<uint8_t> encoded_cbor = map.encode();
+  absl::string_view cbor_view(
+      reinterpret_cast<const char*>(encoded_cbor.data()), encoded_cbor.size());
+
+  // 2. Wrap it in a CborBox.
+  std::string cbor_box = jumbf::EncodeCborBox(cbor_view);
+
+  // 3. Wrap that in a SuperBox (representing an assertion).
+  // Use a standard allowlisted assertion label, e.g., kDataHashAssertionLabel.
+  std::string assertion_box = jumbf::EncodeSuperBox(
+      jumbf::kCborBoxTypeUuid, kDataHashAssertionLabel, {cbor_box},
+      /*requestable=*/true);
+
+  // 4. Wrap that in an assertion store SuperBox.
+  std::string assertion_store_box = jumbf::EncodeSuperBox(
+      kAssertionStoreUuid, kAssertionStoreLabel, {assertion_box});
+
+  // 5. Wrap that in a manifest SuperBox.
+  std::string manifest_box = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "test_manifest", {assertion_store_box});
+
+  // 6. Wrap that in a manifest store SuperBox.
+  std::string manifest_store_box = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest_box});
+
+  // 7. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
+  absl::StatusOr<nlohmann::json> j =
+      ConvertToCrJsonWithoutValidationResults(manifest_store_box);
+  ASSERT_THAT(j, IsOk());
+
+  // 8. Verify that the output JSON contains the base64 encoded byte string.
+  // The expected format is "b64'<base64_data>'".
+  std::string expected_b64 =
+      absl::StrCat("b64'", absl::Base64Escape("raw_bytes_here"), "'");
+  ASSERT_TRUE(j->contains("manifests"));
+  ASSERT_GT((*j)["manifests"].size(), 0);
+  const auto& manifest = (*j)["manifests"][0];
+  ASSERT_TRUE(manifest.contains("assertions"));
+  const auto& assertions = manifest["assertions"];
+  ASSERT_TRUE(assertions.contains(kDataHashAssertionLabel));
+  const auto& assertion_json = assertions[kDataHashAssertionLabel];
+
+  EXPECT_EQ(assertion_json["hash_value"], expected_b64);
+}
+
+TEST(ConvertManifestStoreToCrJsonTest, UnrecognizedAssertionIsIncluded) {
+  // 1. Construct a CBOR map for a dummy assertion.
+  cppbor::Map map;
+  map.add("foo", "bar");
+  std::vector<uint8_t> encoded_cbor = map.encode();
+  absl::string_view cbor_view(
+      reinterpret_cast<const char*>(encoded_cbor.data()), encoded_cbor.size());
+
+  // 2. Wrap it in a CborBox.
+  std::string cbor_box = jumbf::EncodeCborBox(cbor_view);
+
+  // 3. Wrap that in a SuperBox with an unrecognized label.
+  std::string assertion_box = jumbf::EncodeSuperBox(
+      jumbf::kCborBoxTypeUuid, "custom.unrecognized.assertion", {cbor_box},
+      /*requestable=*/true);
+
+  // 4. Wrap that in an assertion store SuperBox.
+  std::string assertion_store_box = jumbf::EncodeSuperBox(
+      kAssertionStoreUuid, kAssertionStoreLabel, {assertion_box});
+
+  // 5. Wrap that in a manifest SuperBox.
+  std::string manifest_box = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "test_manifest", {assertion_store_box});
+
+  // 6. Wrap that in a manifest store SuperBox.
+  std::string manifest_store_box = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest_box});
+
+  // 7. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
+  absl::StatusOr<nlohmann::json> j =
+      ConvertToCrJsonWithoutValidationResults(manifest_store_box);
+  ASSERT_THAT(j, IsOk());
+
+  // 8. Verify that the output JSON contains the unrecognized assertion with
+  // empty object.
+  ASSERT_TRUE(j->contains("manifests"));
+  ASSERT_GT((*j)["manifests"].size(), 0);
+  const auto& manifest = (*j)["manifests"][0];
+  ASSERT_TRUE(manifest.contains("assertions"));
+  const auto& assertions = manifest["assertions"];
+  ASSERT_TRUE(assertions.contains("custom.unrecognized.assertion"));
+  EXPECT_TRUE(assertions["custom.unrecognized.assertion"].is_object());
+  EXPECT_TRUE(assertions["custom.unrecognized.assertion"].empty());
+}
+
+TEST(ConvertManifestStoreToCrJsonTest, HandlesNonSuperBoxInAssertionStore) {
+  // 1. Construct a CBOR box directly. This is not a SuperBox.
+  std::string cbor_box = jumbf::EncodeCborBox("test");
+
+  // 2. Wrap that in an assertion store SuperBox. This is the error condition.
+  std::string assertion_store_box = jumbf::EncodeSuperBox(
+      kAssertionStoreUuid, kAssertionStoreLabel, {cbor_box});
+
+  // 3. Wrap that in a manifest SuperBox.
+  std::string manifest_box = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "test_manifest", {assertion_store_box});
+
+  // 4. Wrap that in a manifest store SuperBox.
+  std::string manifest_store_box = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest_box});
+
+  // 5. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
+  absl::StatusOr<nlohmann::json> j =
+      ConvertToCrJsonWithoutValidationResults(manifest_store_box);
+  ASSERT_THAT(j, IsOk());
+
+  // 6. Verify that the output JSON contains the error message.
+  ASSERT_TRUE(j->contains("manifests"));
+  ASSERT_GT((*j)["manifests"].size(), 0);
+  const auto& manifest = (*j)["manifests"][0];
+  ASSERT_TRUE(manifest.contains("assertions"));
+  const auto& assertions = manifest["assertions"];
+  // The error should be at the top level of the "assertions" object because
+  // the structure is invalid before even getting to a specific assertion label.
+  ASSERT_TRUE(assertions.contains("_error"));
+  EXPECT_EQ(assertions["_error"],
+            "Assertion store contains non-SuperBox content");
+}
+
+TEST(ConvertManifestStoreToCrJsonTest, ClaimV2IsConverted) {
+  // 1. Construct claim_generator_info map.
+  cppbor::Map generator_info;
+  generator_info.add("name", "Test Generator");
+  generator_info.add("version", "1.0");
+
+  // 2. Construct created_assertions array.
+  cppbor::Array created_assertions;
+  cppbor::Map assertion_link;
+  assertion_link.add("url", "self#jumbf=/c2pa/assertions/custom.assertion");
+  created_assertions.add(std::move(assertion_link));
+
+  // 3. Construct the claim map.
+  cppbor::Map claim_map;
+  claim_map.add("instanceID", "test_instance_id");
+  claim_map.add("claim_generator_info", std::move(generator_info));
+  claim_map.add("signature", "self#jumbf=/c2pa/signature");
+  claim_map.add("created_assertions", std::move(created_assertions));
+  claim_map.add("dc:title", "Test Title");
+
+  std::vector<uint8_t> encoded_claim = claim_map.encode();
+  absl::string_view claim_view(
+      reinterpret_cast<const char*>(encoded_claim.data()),
+      encoded_claim.size());
+
+  // 4. Wrap in CborBox.
+  std::string cbor_box = jumbf::EncodeCborBox(claim_view);
+
+  // 5. Wrap in Claim V2 SuperBox.
+  std::string claim_box = jumbf::EncodeSuperBox(jumbf::kCborBoxTypeUuid,
+                                                "c2pa.claim.v2", {cbor_box});
+
+  // 6. Wrap in manifest SuperBox.
+  std::string manifest_box = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "test_manifest", {claim_box});
+
+  // 7. Wrap in manifest store SuperBox.
+  std::string manifest_store_box = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest_box});
+
+  // 8. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
+  absl::StatusOr<nlohmann::json> j =
+      ConvertToCrJsonWithoutValidationResults(manifest_store_box);
+  ASSERT_THAT(j, IsOk());
+
+  // 9. Verify claim.v2 fields in output JSON.
+  ASSERT_TRUE(j->contains("manifests"));
+  ASSERT_GT((*j)["manifests"].size(), 0);
+  const auto& manifest = (*j)["manifests"][0];
+  ASSERT_TRUE(manifest.contains("claim.v2"));
+  const auto& claim_json = manifest["claim.v2"];
+
+  EXPECT_EQ(claim_json["instanceID"], "test_instance_id");
+  EXPECT_EQ(claim_json["dc:title"], "Test Title");
+  EXPECT_EQ(claim_json["signature"], "self#jumbf=/c2pa/signature");
+
+  ASSERT_TRUE(claim_json.contains("claim_generator_info"));
+  EXPECT_EQ(claim_json["claim_generator_info"]["name"], "Test Generator");
+  EXPECT_EQ(claim_json["claim_generator_info"]["version"], "1.0");
+
+  ASSERT_TRUE(claim_json.contains("created_assertions"));
+  ASSERT_GT(claim_json["created_assertions"].size(), 0);
+  EXPECT_EQ(claim_json["created_assertions"][0]["url"],
+            "self#jumbf=/c2pa/assertions/custom.assertion");
+}
+
+TEST(ConvertManifestStoreToCrJsonTest, ClaimV2WithMetadataIsConverted) {
+  // 1. Construct claim_generator_info map.
+  cppbor::Map generator_info;
+  generator_info.add("name", "Test Generator");
+  generator_info.add("version", "1.0");
+
+  // 2. Construct created_assertions array.
+  cppbor::Array created_assertions;
+  cppbor::Map assertion_link;
+  assertion_link.add("url", "self#jumbf=/c2pa/assertions/custom.assertion");
+  created_assertions.add(std::move(assertion_link));
+
+  // 3. Construct metadata map.
+  cppbor::Map metadata_map;
+  metadata_map.add("dateTime", "2023-05-21T00:00:00Z");
+
+  // rating
+  cppbor::Array rating_array;
+  cppbor::Map rating_map;
+  rating_map.add("value", "suitable-for-all");
+  rating_map.add("code", "G");
+  rating_map.add("explanation", "General Audiences");
+  rating_array.add(std::move(rating_map));
+  metadata_map.add("rating", std::move(rating_array));
+
+  // dataSource
+  cppbor::Map datasource_map;
+  datasource_map.add("type", "camera");
+  datasource_map.add("details", "Captured by hardware");
+  metadata_map.add("dataSource", std::move(datasource_map));
+
+  // reference
+  cppbor::Map reference_map;
+  reference_map.add("url", "self#jumbf=/c2pa/assertions/custom.assertion");
+  metadata_map.add("reference", std::move(reference_map));
+
+  // localizations
+  cppbor::Array localizations_array;
+  cppbor::Map localization_map;
+  localization_map.add("en", "English");
+  localizations_array.add(std::move(localization_map));
+  metadata_map.add("localizations", std::move(localizations_array));
+
+  // regionOfInterest
+  cppbor::Map region_map;
+  region_map.add("foo", "bar");
+  metadata_map.add("regionOfInterest", std::move(region_map));
+
+  // 4. Construct the claim map.
+  cppbor::Map claim_map;
+  claim_map.add("instanceID", "test_instance_id");
+  claim_map.add("claim_generator_info", std::move(generator_info));
+  claim_map.add("signature", "self#jumbf=/c2pa/signature");
+  claim_map.add("created_assertions", std::move(created_assertions));
+  claim_map.add("dc:title", "Test Title");
+  claim_map.add("metadata", std::move(metadata_map));
+
+  std::vector<uint8_t> encoded_claim = claim_map.encode();
+  absl::string_view claim_view(
+      reinterpret_cast<const char*>(encoded_claim.data()),
+      encoded_claim.size());
+
+  // 5. Wrap in CborBox.
+  std::string cbor_box = jumbf::EncodeCborBox(claim_view);
+
+  // 6. Wrap in Claim V2 SuperBox.
+  std::string claim_box = jumbf::EncodeSuperBox(jumbf::kCborBoxTypeUuid,
+                                                "c2pa.claim.v2", {cbor_box});
+
+  // 7. Wrap in manifest SuperBox.
+  std::string manifest_box = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "test_manifest", {claim_box});
+
+  // 8. Wrap in manifest store SuperBox.
+  std::string manifest_store_box = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest_box});
+
+  // 9. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
+  absl::StatusOr<nlohmann::json> j =
+      ConvertToCrJsonWithoutValidationResults(manifest_store_box);
+  ASSERT_THAT(j, IsOk());
+
+  // 10. Verify metadata fields in output JSON.
+  ASSERT_TRUE(j->contains("manifests"));
+  ASSERT_GT((*j)["manifests"].size(), 0);
+  const auto& manifest = (*j)["manifests"][0];
+  ASSERT_TRUE(manifest.contains("claim.v2"));
+  const auto& claim_json = manifest["claim.v2"];
+
+  ASSERT_TRUE(claim_json.contains("metadata"));
+  const auto& metadata_json = claim_json["metadata"];
+
+  EXPECT_EQ(metadata_json["dateTime"], "2023-05-21T00:00:00Z");
+
+  ASSERT_TRUE(metadata_json.contains("rating"));
+  ASSERT_GT(metadata_json["rating"].size(), 0);
+  EXPECT_EQ(metadata_json["rating"][0]["value"], "suitable-for-all");
+  EXPECT_EQ(metadata_json["rating"][0]["code"], "G");
+  EXPECT_EQ(metadata_json["rating"][0]["explanation"], "General Audiences");
+
+  ASSERT_TRUE(metadata_json.contains("dataSource"));
+  EXPECT_EQ(metadata_json["dataSource"]["type"], "camera");
+  EXPECT_EQ(metadata_json["dataSource"]["details"], "Captured by hardware");
+
+  ASSERT_TRUE(metadata_json.contains("reference"));
+  EXPECT_EQ(metadata_json["reference"]["url"],
+            "self#jumbf=/c2pa/assertions/custom.assertion");
+
+  // Localizations and RegionOfInterest are currently converted to empty objects
+  // but we still verify they exist.
+  ASSERT_TRUE(metadata_json.contains("localizations"));
+  EXPECT_TRUE(metadata_json["localizations"].is_array());
+  ASSERT_TRUE(metadata_json.contains("regionOfInterest"));
+  EXPECT_TRUE(metadata_json["regionOfInterest"].is_object());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/crjson_utils.cc b/utils/crjson_utils.cc
new file mode 100644
index 0000000..9ee3800
--- /dev/null
+++ b/utils/crjson_utils.cc
@@ -0,0 +1,165 @@
+// 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 "utils/crjson_utils.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/functional/function_ref.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "cbor/cbor.h"
+#include "cppbor/cppbor.h"
+#include "nlohmann/json.hpp"
+
+namespace credentio {
+
+using Json = ::nlohmann::json;
+
+bool RecordString(const cbor::MapView& map, absl::string_view key,
+                  bool required, Json& json) {
+  if (auto val = map.GetOptionalString(key); val.has_value()) {
+    json[key] = *val;
+    return true;
+  }
+  if (required) {
+    json["_missing"].push_back(key);
+  }
+  return false;
+}
+
+bool RecordByteString(const cbor::MapView& map, absl::string_view key,
+                      bool required, Json& json) {
+  if (auto val = map.GetOptionalByteString(key); val.has_value()) {
+    json[key] = absl::StrCat("b64'", absl::Base64Escape(*val), "'");
+    return true;
+  }
+  if (required) {
+    json["_missing"].push_back(key);
+  }
+  return false;
+}
+
+bool RecordByteString(const cbor::MapView& map, uint32_t key, bool required,
+                      Json& json) {
+  if (auto val = map.GetByteString(key); val.ok()) {
+    json[std::to_string(key)] =
+        absl::StrCat("b64'", absl::Base64Escape(*val), "'");
+    return true;
+  }
+  if (required) {
+    json["_missing"].push_back(std::to_string(key));
+  }
+  return false;
+}
+
+bool RecordUint64(const cbor::MapView& map, absl::string_view key,
+                  bool required, Json& json) {
+  if (auto val = map.GetOptionalUint64(key); val.has_value()) {
+    json[key] = *val;
+    return true;
+  }
+  if (required) {
+    json["_missing"].push_back(key);
+  }
+  return false;
+}
+
+bool RecordInt64(const cbor::MapView& map, absl::string_view key, bool required,
+                 Json& json) {
+  if (auto val = map.GetOptionalInt64(key); val.has_value()) {
+    json[key] = *val;
+    return true;
+  }
+  if (required) {
+    json["_missing"].push_back(key);
+  }
+  return false;
+}
+
+bool RecordBool(const cbor::MapView& map, absl::string_view key, bool required,
+                Json& json) {
+  if (auto val = map.GetOptionalBool(key); val.has_value()) {
+    json[key] = *val;
+    return true;
+  }
+  if (required) {
+    json["_missing"].push_back(key);
+  }
+  return false;
+}
+
+bool RecordArray(const cbor::MapView& map, absl::string_view key, bool required,
+                 ArrayProcessor processor, Json& json) {
+  if (auto val = map.GetOptionalArray(key); val.has_value()) {
+    json[key] = Json::array();
+    for (int i = 0; i < val->size(); ++i) {
+      auto elem = val->Get(i);
+      if (!elem.ok()) {
+        json[key].push_back(Json({{"_error", elem.status().message()}}));
+        continue;
+      }
+      json[key].push_back(processor(i, *elem));
+    }
+    return true;
+  }
+  if (required) {
+    json[key] = Json::array();
+  }
+  return false;
+}
+
+bool RecordArrayOfMaps(const cbor::MapView& map, absl::string_view key,
+                       bool required, MapProcessor processor, Json& json) {
+  if (auto val = map.GetOptionalArray(key); val.has_value()) {
+    json[key] = Json::array();
+    for (int i = 0; i < val->size(); ++i) {
+      auto elem = val->Get(i);
+      if (!elem.ok()) {
+        json[key].push_back(Json({{"_error", elem.status().message()}}));
+        continue;
+      }
+      auto map = elem->GetMap();
+      if (!map.ok()) {
+        json[key].push_back(Json({{"_error", elem.status().message()}}));
+        continue;
+      }
+      json[key].push_back(processor(*map));
+    }
+    return true;
+  }
+  if (required) {
+    json[key] = Json::array();
+  }
+  return false;
+}
+
+bool RecordMap(const cbor::MapView& map, absl::string_view key, bool required,
+               MapProcessor processor, Json& json) {
+  if (auto val = map.GetOptionalMap(key); val.has_value()) {
+    json[key] = processor(*val);
+    return true;
+  }
+  if (required) {
+    cppbor::Map map;
+    cbor::MapView map_view(&map);
+    json[key] = processor(map_view);
+  }
+  return false;
+}
+
+}  // namespace credentio
diff --git a/utils/crjson_utils.h b/utils/crjson_utils.h
new file mode 100644
index 0000000..4ae8064
--- /dev/null
+++ b/utils/crjson_utils.h
@@ -0,0 +1,127 @@
+// 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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_UTILS_CRJSON_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_CRJSON_UTILS_H_
+
+#include <cstdint>
+
+#include "absl/functional/function_ref.h"
+#include "absl/strings/string_view.h"
+#include "cbor/cbor.h"
+#include "nlohmann/json_fwd.hpp"
+
+namespace credentio {
+
+// Record a string value from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordString(const cbor::MapView& map, absl::string_view key,
+                  bool required, nlohmann::json& json);
+
+// Record a byte string value from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordByteString(const cbor::MapView& map, uint32_t key, bool required,
+                      nlohmann::json& json);
+
+// Record a byte string value from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordByteString(const cbor::MapView& map, absl::string_view key,
+                      bool required, nlohmann::json& json);
+
+// Record a byte string value from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordUint64(const cbor::MapView& map, absl::string_view key,
+                  bool required, nlohmann::json& json);
+
+// Record an int64 value from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordInt64(const cbor::MapView& map, absl::string_view key, bool required,
+                 nlohmann::json& json);
+
+// Record a bool value from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordBool(const cbor::MapView& map, absl::string_view key, bool required,
+                nlohmann::json& json);
+
+// A function that processes an array element.
+//
+// The function is called for each element in the array and the index of the
+// element in the array. The function should return a JSON object that
+// represents the element.
+using ArrayProcessor = absl::FunctionRef<nlohmann::json(
+    uint32_t index, const cbor::ItemView& item)>;
+
+// A function that processes a map.
+//
+// The function is called for each map in the array of maps and should return a
+// JSON object that represents the map.
+using MapProcessor =
+    absl::FunctionRef<nlohmann::json(const cbor::MapView& map)>;
+
+// Record an array from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordArray(const cbor::MapView& map, absl::string_view key, bool required,
+                 ArrayProcessor processor, nlohmann::json& json);
+
+// Record an array of maps from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordArrayOfMaps(const cbor::MapView& map, absl::string_view key,
+                       bool required, MapProcessor processor,
+                       nlohmann::json& json);
+
+// Record a map from the map to the JSON.
+//
+// If `required` is true, the key must be present in the map, otherwise the
+// function will add the key to the `_missing` array in the JSON.
+//
+// Returns true if the key was present in the map, false otherwise.
+bool RecordMap(const cbor::MapView& map, absl::string_view key, bool required,
+               MapProcessor processor, nlohmann::json& json);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_CRJSON_UTILS_H_
diff --git a/utils/crjson_utils_test.cc b/utils/crjson_utils_test.cc
new file mode 100644
index 0000000..23dd0ce
--- /dev/null
+++ b/utils/crjson_utils_test.cc
@@ -0,0 +1,218 @@
+// 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 "utils/crjson_utils.h"
+
+#include <cstdint>
+#include <utility>
+
+#include "cbor/cbor.h"
+#include "cppbor/cppbor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "nlohmann/json.hpp"
+
+namespace credentio {
+namespace {
+
+using Json = ::nlohmann::json;
+using ::testing::ElementsAre;
+
+TEST(UtilsTest, RecordStringPresent) {
+  cppbor::Map map;
+  map.add("name", "John");
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_TRUE(RecordString(map_view, "name", true, json));
+  EXPECT_EQ(json["name"], "John");
+}
+
+TEST(UtilsTest, RecordStringMissingRequired) {
+  cppbor::Map map;
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_FALSE(RecordString(map_view, "name", true, json));
+  EXPECT_THAT(json["_missing"], ElementsAre("name"));
+}
+
+TEST(UtilsTest, RecordStringMissingNotRequired) {
+  cppbor::Map map;
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_FALSE(RecordString(map_view, "name", false, json));
+  EXPECT_FALSE(json.contains("name"));
+  EXPECT_FALSE(json.contains("_missing"));
+}
+
+TEST(UtilsTest, RecordByteStringPresent) {
+  cppbor::Map map;
+  map.add("hash", cppbor::Bstr("data"));
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_TRUE(RecordByteString(map_view, "hash", true, json));
+  EXPECT_EQ(json["hash"], "b64'ZGF0YQ=='");
+}
+
+TEST(UtilsTest, RecordByteStringUint32KeyPresent) {
+  cppbor::Map map;
+  map.add(1, cppbor::Bstr("data"));
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_TRUE(RecordByteString(map_view, 1, true, json));
+  EXPECT_EQ(json["1"], "b64'ZGF0YQ=='");
+}
+
+TEST(UtilsTest, RecordUint64Present) {
+  cppbor::Map map;
+  map.add("size", 100);
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_TRUE(RecordUint64(map_view, "size", true, json));
+  EXPECT_EQ(json["size"], 100);
+}
+
+TEST(UtilsTest, RecordUint64MissingRequired) {
+  cppbor::Map map;
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_FALSE(RecordUint64(map_view, "size", true, json));
+  EXPECT_THAT(json["_missing"], ElementsAre("size"));
+}
+
+TEST(UtilsTest, RecordUint64MissingNotRequired) {
+  cppbor::Map map;
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_FALSE(RecordUint64(map_view, "size", false, json));
+  EXPECT_FALSE(json.contains("size"));
+  EXPECT_FALSE(json.contains("_missing"));
+}
+
+TEST(UtilsTest, RecordInt64Present) {
+  cppbor::Map map;
+  map.add("val", -100);
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_TRUE(RecordInt64(map_view, "val", true, json));
+  EXPECT_EQ(json["val"], -100);
+}
+
+TEST(UtilsTest, RecordBoolPresent) {
+  cppbor::Map map;
+  map.add("flag", true);
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  EXPECT_TRUE(RecordBool(map_view, "flag", true, json));
+  EXPECT_TRUE(json["flag"]);
+}
+
+TEST(UtilsTest, RecordArrayPresent) {
+  cppbor::Map map;
+  cppbor::Array array;
+  array.add("a");
+  array.add("b");
+  map.add("list", std::move(array));
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  auto processor = [](uint32_t index, const cbor::ItemView& item) {
+    if (auto val = item.GetString(); val.ok()) {
+      return Json(*val);
+    }
+    return Json({{"_error", item.GetString().status().message()}});
+  };
+
+  EXPECT_TRUE(RecordArray(map_view, "list", true, processor, json));
+  EXPECT_THAT(json["list"], ElementsAre("a", "b"));
+}
+
+TEST(UtilsTest, RecordArrayMissingRequired) {
+  cppbor::Map map;
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  auto processor = [](uint32_t index, const cbor::ItemView& item) {
+    if (auto val = item.GetString(); val.ok()) {
+      return Json(*val);
+    }
+    return Json({{"_error", item.GetString().status().message()}});
+  };
+
+  EXPECT_FALSE(RecordArray(map_view, "list", true, processor, json));
+  EXPECT_TRUE(json["list"].is_array());
+  EXPECT_TRUE(json["list"].empty());
+}
+
+TEST(UtilsTest, RecordArrayOfMapsPresent) {
+  cppbor::Map map;
+  cppbor::Array array;
+  cppbor::Map item1;
+  item1.add("k1", "v1");
+  cppbor::Map item2;
+  item2.add("k2", "v2");
+  array.add(std::move(item1));
+  array.add(std::move(item2));
+  map.add("list", std::move(array));
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  auto processor = [](const cbor::MapView& m) {
+    Json j = Json::object();
+    if (auto val = m.GetOptionalString("k1"); val.has_value()) {
+      j["k1"] = *val;
+    }
+    if (auto val = m.GetOptionalString("k2"); val.has_value()) {
+      j["k2"] = *val;
+    }
+    return j;
+  };
+
+  EXPECT_TRUE(RecordArrayOfMaps(map_view, "list", true, processor, json));
+  EXPECT_EQ(json["list"][0]["k1"], "v1");
+  EXPECT_EQ(json["list"][1]["k2"], "v2");
+}
+
+TEST(UtilsTest, RecordMapPresent) {
+  cppbor::Map map;
+  cppbor::Map sub_map;
+  sub_map.add("sub_key", "sub_val");
+  map.add("map_key", std::move(sub_map));
+  cbor::MapView map_view(&map);
+  Json json = Json::object();
+
+  auto processor = [](const cbor::MapView& m) {
+    Json j = Json::object();
+    if (auto val = m.GetString("sub_key"); val.ok()) {
+      j["sub_key"] = *val;
+    }
+    return j;
+  };
+
+  EXPECT_TRUE(RecordMap(map_view, "map_key", true, processor, json));
+  EXPECT_EQ(json["map_key"]["sub_key"], "sub_val");
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/media_type.cc b/utils/media_type.cc
index 5ecd6be..74a6e16 100644
--- a/utils/media_type.cc
+++ b/utils/media_type.cc
@@ -56,6 +56,7 @@
           {"tif", "image/tiff"},
           {"tiff", "image/tiff"},
           {"wav", "audio/wav"},  // Not formally registered.
+          {"avi", "video/x-msvideo"},
           {"gif", "image/gif"},
           {"mp3", "audio/mpeg"},
           {"flac", "audio/flac"},
diff --git a/utils/media_type_test.cc b/utils/media_type_test.cc
index 9b75dda..f78127f 100644
--- a/utils/media_type_test.cc
+++ b/utils/media_type_test.cc
@@ -41,6 +41,7 @@
   EXPECT_THAT(MediaType("foo.tif"), IsOkAndHolds("image/tiff"));
   EXPECT_THAT(MediaType("foo.tiff"), IsOkAndHolds("image/tiff"));
   EXPECT_THAT(MediaType("foo.wav"), IsOkAndHolds("audio/wav"));
+  EXPECT_THAT(MediaType("foo.avi"), IsOkAndHolds("video/x-msvideo"));
   EXPECT_THAT(MediaType("foo.gif"), IsOkAndHolds("image/gif"));
   EXPECT_THAT(MediaType("foo.m4a"), IsOkAndHolds("audio/mp4"));
   EXPECT_THAT(MediaType("foo.mp3"), IsOkAndHolds("audio/mpeg"));