Public release

GitOrigin-RevId: bc0df9de9abe54f0c2d7dc3522d8b1f4f6675021
Change-Id: I617787e963f4bb77d849fcae87aab5b94db39eb2
diff --git a/.bazelrc b/.bazelrc
new file mode 100644
index 0000000..a9dda58
--- /dev/null
+++ b/.bazelrc
@@ -0,0 +1,13 @@
+# Default C++ version
+build --cxxopt=-std=c++20
+
+# Force Clang to use LLVM's libc++ instead of the old system libstdc++
+build --cxxopt=-stdlib=libc++
+build --linkopt=-stdlib=libc++
+
+# Mac-specific setting
+build --macos_minimum_os=10.13
+
+# Use clang for C++ compilation
+build --repo_env=CC=clang
+build --repo_env=CXX=clang++
\ No newline at end of file
diff --git a/BUILD.bazel b/BUILD.bazel
new file mode 100644
index 0000000..12f0996
--- /dev/null
+++ b/BUILD.bazel
@@ -0,0 +1,15 @@
+# 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.
+#
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..e8f2e04
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,30 @@
+# How to contribute
+
+We'd love to accept your patches and contributions to this project.
+
+## Before you begin
+
+### Sign our Contributor License Agreement
+
+Contributions to this project must be accompanied by a
+[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
+You (or your employer) retain the copyright to your contribution; this simply
+gives us permission to use and redistribute your contributions as part of the
+project.
+
+If you or your current employer have already signed the Google CLA (even if it
+was for a different project), you probably don't need to do it again.
+
+Visit <https://cla.developers.google.com/> to see your current agreements or to
+sign a new one.
+
+### Review our community guidelines
+
+This project follows
+[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
+
+## Contribution process
+
+### Code reviews
+
+All submissions, including submissions by project members, require review.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   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
+
+       http://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.
diff --git a/MODULE.bazel b/MODULE.bazel
new file mode 100644
index 0000000..0c82647
--- /dev/null
+++ b/MODULE.bazel
@@ -0,0 +1,68 @@
+# 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.
+#
+
+module(
+    name = "c2pa",
+    version = "0.1.0",
+)
+
+bazel_dep(name = "abseil-cpp", version = "20260526.0")
+bazel_dep(name = "googletest", version = "1.17.0.bcr.2")
+bazel_dep(name = "protobuf", version = "34.1")
+bazel_dep(name = "rules_cc", version = "0.2.17")
+bazel_dep(name = "rules_foreign_cc", version = "0.15.1")
+bazel_dep(name = "riegeli", version = "0.0.0-20250822-9f2744d")
+bazel_dep(name = "nlohmann_json", version = "3.12.0.bcr.1")
+bazel_dep(name = "boringssl", version = "0.20260413.0")
+bazel_dep(name = "tink_cc", version = "2.7.0")
+bazel_dep(name = "re2", version = "2025-11-05.bcr.1")
+
+# Add rules_android and its dependencies
+bazel_dep(name = "rules_java", version = "9.6.1")  # Often needed by rules_android
+bazel_dep(name = "bazel_skylib", version = "1.9.0")  # Often needed by rules_android
+bazel_dep(name = "rules_android", version = "0.7.1")
+
+single_version_override(
+    module_name = "rules_go",
+    version = "0.60.0",
+)
+
+# Configure Android SDK
+remote_android_extensions = use_extension(
+    "@rules_android//bzlmod_extensions:android_extensions.bzl",
+    "remote_android_tools_extensions",
+)
+use_repo(remote_android_extensions, "android_tools")
+
+android_sdk_repository_extension = use_extension("@rules_android//rules/android_sdk_repository:rule.bzl", "android_sdk_repository_extension")
+use_repo(android_sdk_repository_extension, "androidsdk")
+
+register_toolchains("@androidsdk//:sdk-toolchain", "@androidsdk//:all")
+
+http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+http_archive(
+    name = "libcppbor",
+    build_file = "@//:external/libcppbor.BUILD",
+    urls = ["https://android.googlesource.com/platform/system/libcppbor/+archive/refs/heads/main.tar.gz"],
+)
+
+archive_override(
+    module_name = "boringssl",
+    patch_strip = 1,
+    patches = ["@//:external/boringssl_pki_visibility.patch"],
+    strip_prefix = "boringssl-0.20260413.0",
+    urls = ["https://github.com/google/boringssl/archive/0.20260413.0.tar.gz"],
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7612f3c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,99 @@
+# Project Credentio
+
+C++ libraries to support validation and generation of C2PA Content Credentials
+(https://c2pa.org/).
+
+NOTE: We are actively developing this project and may introduce breaking changes
+without notice.
+
+## Supported Media Formats
+
+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`                   |
+
+## Prerequisites
+
+*   Clang
+*   Bazel
+*   Git
+
+## Dependencies
+
+*   [Abseil (`abseil-cpp`)](https://abseil.io/)
+*   [Riegeli](https://github.com/google/riegeli)
+*   [BoringSSL](https://github.com/google/boringssl)
+*   [GoogleTest (`googletest`)](https://github.com/google/googletest)
+*   [Google Tink (`tink_cc`)](https://github.com/tink-crypto/tink-cc)
+*   [LibCppBor](https://android.googlesource.com/platform/system/libcppbor/)
+*   [nlohmann_json](https://github.com/nlohmann/json)
+*   [Protocol Buffers (`protobuf`)](https://github.com/protocolbuffers/protobuf)
+
+## Quickstart
+
+Project Credentio includes a fast standalone command-line tool for inspecting
+and verifying C2PA metadata embedded in media assets.
+
+#### Building the CLI
+
+```bash
+bazel build tools:c2pa_validate
+```
+
+#### Validating with Trust Anchors
+
+You can validate assets against specific PEM-encoded trust anchors for claim
+signers and Time Stamping Authorities (TSA):
+
+```bash
+bazel run tools:c2pa_validate -- \
+  --asset=/path/to/asset.jpg \
+  --claim_signer_trust=/path/to/claim_signer_trust_anchors.pem \
+  --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
+> ([https://github.com/c2pa-org/conformance-public/tree/main/trust-list](https://github.com/c2pa-org/conformance-public/tree/main/trust-list)).
+
+## Building & Testing
+
+To build the entire project:
+
+```bash
+bazel build ...
+```
+
+To execute all unit tests:
+
+```bash
+bazel test ...
+```
+
+## 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
+project and may introduce breaking changes without notice.
+
+## License & Disclaimer
+
+Project Credentio is licensed under the terms of the Apache 2.0 License. See
+[LICENSE](LICENSE) for more information.
+
+### Disclaimer
+
+This is not an officially supported Google product. This project is not eligible
+for the
+[Google Open Source Software Vulnerability Rewards Program](https://bughunters.google.com/open-source-security).
+
+## Contact
+
+For questions, feedback, or inquiries, please contact: `c2pa-core@google.com`.
diff --git a/assertion/BUILD b/assertion/BUILD
new file mode 100644
index 0000000..a7250bb
--- /dev/null
+++ b/assertion/BUILD
@@ -0,0 +1,405 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "parse_assertion",
+    srcs = ["parse_assertion.cc"],
+    hdrs = ["parse_assertion.h"],
+    deps = [
+        "//cbor:parse",
+        "//proto:actions_assertion_cbor_proto",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:ai_disclosure_assertion_cbor_proto",
+        "//proto:ai_disclosure_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:asset_reference_assertion_cbor_proto",
+        "//proto:asset_reference_assertion_cc_proto",
+        "//proto:asset_types_assertion_cbor_proto",
+        "//proto:asset_types_assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cbor_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cbor_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:collection_data_hash_assertion_cbor_proto",
+        "//proto:collection_data_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cbor_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:environmental_sustainability_assertion_cbor_proto",
+        "//proto:environmental_sustainability_assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cbor_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:metadata_assertion_cc_proto",
+        "//proto:multi_asset_hash_assertion_cbor_proto",
+        "//proto:multi_asset_hash_assertion_cc_proto",
+        "//proto:repository_receipt_assertion_cc_proto",
+        "//proto:soft_binding_assertion_cbor_proto",
+        "//proto:soft_binding_assertion_cc_proto",
+        "//proto:thumbnail_assertion_cc_proto",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings:string_view",
+        "@nlohmann_json//:json",
+        "@protobuf//:struct_cc_proto",
+    ],
+)
+
+cc_test(
+    name = "parse_assertion_test",
+    srcs = ["parse_assertion_test.cc"],
+    deps = [
+        ":parse_assertion",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:ai_disclosure_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:asset_reference_assertion_cc_proto",
+        "//proto:asset_types_assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:collection_data_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:environmental_sustainability_assertion_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:repository_receipt_assertion_cc_proto",
+        "//testing:cbor_utils",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@nlohmann_json//:json",
+        "@protobuf//:struct_cc_proto",
+    ],
+)
+
+cc_library(
+    name = "assertion_encoder",
+    srcs = ["assertion_encoder.cc"],
+    hdrs = ["assertion_encoder.h"],
+    deps = [
+        "//cbor",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//proto:actions_assertion_cbor_proto",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cbor_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cbor_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:collection_data_hash_assertion_cbor_proto",
+        "//proto:collection_data_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cbor_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:ingredient_assertion_cbor_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:multi_asset_hash_assertion_cbor_proto",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "assertion_encoder_test",
+    srcs = ["assertion_encoder_test.cc"],
+    deps = [
+        ":assertion_encoder",
+        ":assertion_parser",
+        "//cbor",
+        "//cbor:parse",
+        "//jumbf:box",
+        "//jumbf:parse",
+        "//proto:actions_assertion_cbor_proto",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cbor_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cbor_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cbor_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:ingredient_assertion_cbor_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//testing:proto_test_utils",
+        "//testing:test_validation_tracker",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "hashed_uri_validator",
+    srcs = ["hashed_uri_validator.cc"],
+    hdrs = ["hashed_uri_validator.h"],
+    deps = [
+        "//constants:status_codes",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//jumbf:uri",
+        "//jumbf:utils",
+        "//proto:hashed_uri_cc_proto",
+        "//validator:tracker",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "hashed_uri_validator_test",
+    srcs = ["hashed_uri_validator_test.cc"],
+    deps = [
+        ":hashed_uri_validator",
+        "//constants:status_codes",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//jumbf:box",
+        "//jumbf:uri",
+        "//proto:hashed_uri_cc_proto",
+        "//testing:jumbf_utils",
+        "//testing:proto_test_utils",
+        "//testing:test_validation_tracker",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "references_validator",
+    srcs = ["references_validator.cc"],
+    hdrs = ["references_validator.h"],
+    deps = [
+        ":assertion_parser",
+        ":hashed_uri_validator",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//validator:tracker",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "references_validator_test",
+    srcs = ["references_validator_test.cc"],
+    deps = [
+        ":hashed_uri_validator",
+        ":references_validator",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//jumbf:box",
+        "//jumbf:uri",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//testing:jumbf_utils",
+        "//testing:proto_test_utils",
+        "//testing:test_validation_tracker",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "assertion_parser",
+    srcs = ["assertion_parser.cc"],
+    hdrs = ["assertion_parser.h"],
+    deps = [
+        ":parse_assertion",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//proto:assertion_cc_proto",
+        "//validator:tracker",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@protobuf//:struct_cc_proto",
+    ],
+)
+
+cc_test(
+    name = "assertion_parser_test",
+    srcs = ["assertion_parser_test.cc"],
+    deps = [
+        ":assertion_parser",
+        "//constants:status_codes",
+        "//proto:assertion_cc_proto",
+        "//testing:cbor_utils",
+        "//testing:jumbf_utils",
+        "//testing:proto_test_utils",
+        "//testing:test_validation_tracker",
+        "//validator:tracker",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "actions_assertion_validator",
+    srcs = ["actions_assertion_validator.cc"],
+    hdrs = ["actions_assertion_validator.h"],
+    deps = [
+        "//constants:ingredient_relationships",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//validator:tracker",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "actions_assertion_validator_test",
+    srcs = ["actions_assertion_validator_test.cc"],
+    deps = [
+        ":actions_assertion_validator",
+        "//constants:status_codes",
+        "//proto:manifest_cc_proto",
+        "//testing:proto_test_utils",
+        "//testing:test_validation_tracker",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "assertion_referencer",
+    srcs = ["assertion_referencer.cc"],
+    hdrs = ["assertion_referencer.h"],
+    deps = [
+        "//crypto:hash",
+        "//jumbf:box_builder",
+        "//jumbf:utils",
+        "//proto:assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
+    ],
+)
+
+cc_test(
+    name = "assertion_referencer_test",
+    srcs = ["assertion_referencer_test.cc"],
+    deps = [
+        ":assertion_referencer",
+        "//crypto:mock_hash",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//jumbf:test_utils",
+        "//proto:assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "validator",
+    srcs = ["validator.cc"],
+    hdrs = ["validator.h"],
+    deps = [
+        ":actions_assertion_validator",
+        ":assertion_parser",
+        ":hashed_uri_validator",
+        ":references_validator",
+        "//constants:ingredient_relationships",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//jumbf:box",
+        "//jumbf:uri",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//validator:tracker",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@protobuf//:protobuf_lite",
+    ],
+)
+
+cc_test(
+    name = "validator_test",
+    srcs = ["validator_test.cc"],
+    deps = [
+        ":validator",
+        "//constants:status_codes",
+        "//jumbf:box",
+        "//jumbf:uri",
+        "//proto:assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//testing:cbor_utils",
+        "//testing:jumbf_utils",
+        "//testing:proto_test_utils",
+        "//testing:test_validation_tracker",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/assertion/actions_assertion_validator.cc b/assertion/actions_assertion_validator.cc
new file mode 100644
index 0000000..97d627d
--- /dev/null
+++ b/assertion/actions_assertion_validator.cc
@@ -0,0 +1,269 @@
+// 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 "assertion/actions_assertion_validator.h"
+
+#include <string>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "constants/ingredient_relationships.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+namespace {
+
+absl::string_view Basename(absl::string_view path) {
+  return path.substr(path.find_last_of('/') + 1);
+}
+
+bool RequiresParentOfRelationship(absl::string_view action) {
+  return action == "c2pa.opened" || action == "c2pa.repackaged" ||
+         action == "c2pa.transcoded";
+}
+
+bool RequiresComponentOfRelationship(absl::string_view action) {
+  return action == "c2pa.placed" || action == "c2pa.removed";
+}
+
+bool RequiresIngredientValidation(absl::string_view action) {
+  return RequiresParentOfRelationship(action) ||
+         RequiresComponentOfRelationship(action);
+}
+
+template <class IngredientAssertionType>
+bool ValidateIngredientRelationship(absl::string_view action,
+                                    const IngredientAssertionType& ingredient,
+                                    absl::string_view assertion_url,
+                                    ValidationTracker& validation_tracker) {
+  // 3.c.ii.A. Check that the ingredient relationship is parentOf.
+  if (RequiresParentOfRelationship(action)) {
+    if (ingredient.relationship() != kIngredientRelationshipParentOf) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionActionIngredientMismatch,
+          {.url = assertion_url,
+           .explanation = absl::StrFormat(
+               "action %s has wrong ingredient relationship: %s; want %s",
+               action, ingredient.relationship(),
+               kIngredientRelationshipParentOf)});
+      return false;
+    }
+  }
+  // 3.c.ii.B. Check that the ingredient relationship is componentOf.
+  if (RequiresComponentOfRelationship(action)) {
+    if (ingredient.relationship() != kIngredientRelationshipComponentOf) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionActionIngredientMismatch,
+          {.url = assertion_url,
+           .explanation = absl::StrFormat(
+               "action %s has wrong ingredient relationship: %s; "
+               "want %s",
+               action, ingredient.relationship(),
+               kIngredientRelationshipComponentOf)});
+      return false;
+    }
+  }
+  return true;
+}
+
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_actions_validation.
+template <class ActionsAssertionType>
+bool ValidateActions(
+    const ActionsAssertionType& assertion,
+    absl::flat_hash_map<std::string, const Assertion*> assertion_store,
+    ValidationTracker& validation_tracker) {
+  // 1. Ensure that it has an actions field.
+  if (assertion.actions().empty()) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionActionMalformed, {});
+    return false;
+  }
+  for (auto i = 0; i < assertion.actions().size(); ++i) {
+    const auto& action = assertion.actions(i);
+    // 3.a. Ensure that it has an action field.
+    if (action.action().empty()) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionActionMalformed,
+          {.explanation = "empty action"});
+      return false;
+    }
+    // 3.b. If the action field is c2pa.created or c2pa.opened, it must be the
+    // first action.
+    if (action.action() == "c2pa.created" || action.action() == "c2pa.opened") {
+      if (i != 0) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kAssertionActionMalformed,
+            {.explanation = "created/opened not first action"});
+        return false;
+      }
+    }
+    // 3.c. Check ingredient for certain actions.
+    if (RequiresIngredientValidation(action.action())) {
+      // 3.c.i. Check ingredients are present.
+      if (!action.has_parameters()) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kAssertionActionIngredientMismatch,
+            {.explanation = absl::StrFormat("action %s missing parameters",
+                                            action.action())});
+        return false;
+      }
+      if (action.parameters().ingredients().size() == 0) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kAssertionActionIngredientMismatch,
+            {.explanation = absl::StrFormat("action %s missing ingredients",
+                                            action.action())});
+        return false;
+      }
+      // 3.c.ii. For each ingredient, check that it has a url and it is valid.
+      for (const auto& ingredient : action.parameters().ingredients()) {
+        if (!ingredient.has_url() || ingredient.url().empty()) {
+          validation_tracker.RecordFailure(
+              FailureStatusCode::kAssertionActionIngredientMismatch,
+              {.explanation = absl::StrFormat(
+                   "action %s missing ingredient url", action.action())});
+          return false;
+        }
+        auto label = Basename(ingredient.url());
+        if (!assertion_store.contains(label)) {
+          validation_tracker.RecordFailure(
+              FailureStatusCode::kAssertionActionIngredientMismatch,
+              {.explanation = absl::StrFormat(
+                   "action %s missing ingredient assertion", action.action())});
+          return false;
+        }
+        auto* assertion = assertion_store[label];
+        std::string assertion_url = absl::StrCat(
+            kAssertionStoreLabel, kManifestLabelDelimiter, assertion->label());
+        if (assertion->has_ingredient_v3()) {
+          if (!ValidateIngredientRelationship(
+                  action.action(), assertion->ingredient_v3(), assertion_url,
+                  validation_tracker)) {
+            return false;
+          }
+        } else {
+          validation_tracker.RecordFailure(
+              FailureStatusCode::kAssertionActionIngredientMismatch,
+              {.url = assertion_url,
+               .explanation = absl::StrFormat(
+                   "action %s missing ingredient assertion", action.action())});
+          return false;
+        }
+      }
+    }
+    // 3.d. If the action is c2pa.redacted, check that the redacted field that
+    // is a member of the parameters object for the presence of a JUMBF URI and
+    // that the URI resolves to an assertion.
+    if (action.action() == "c2pa.redacted") {
+      if (action.parameters().redacted_uri().empty()) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kAssertionActionRedactionMismatch,
+            {.explanation = absl::StrFormat(
+                 "action %s missing redacted in parameters", action.action())});
+        return false;
+      }
+      auto redacted_label = Basename(action.parameters().redacted_uri());
+      if (!assertion_store.contains(redacted_label)) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kAssertionActionRedactionMismatch,
+            {.explanation = absl::StrFormat(
+                 "action %s missing redacted assertion", action.action())});
+        return false;
+      }
+    }
+  }
+  return true;
+}
+
+// 2. Check the first action in each actions assertion.
+template <class ActionsAssertionType>
+bool ValidateFirstAction(absl::string_view label,
+                         const ActionsAssertionType& assertion,
+                         bool is_first_action_assertion,
+                         ValidationTracker& validation_tracker) {
+  bool is_created_or_opened =
+      assertion.actions_size() > 0 &&
+      (assertion.actions(0).action() == "c2pa.created" ||
+       assertion.actions(0).action() == "c2pa.opened");
+  if (is_first_action_assertion && !is_created_or_opened) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionActionMalformed,
+        {.explanation = "the first action is not c2pa.created or c2pa.opened"});
+    return false;
+  }
+  if (is_created_or_opened && !is_first_action_assertion) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionActionMalformed,
+        {.explanation = "c2pa.created or c2pa.opened is not the first action"});
+    return false;
+  }
+  return true;
+}
+
+}  // namespace
+
+bool ActionsAssertionValidator::Validate(
+    const Manifest& manifest, ValidationTracker& validation_tracker) const {
+  absl::flat_hash_map<std::string, const Assertion*> assertion_store;
+  for (const auto& assertion : manifest.assertions()) {
+    assertion_store[assertion.label()] = &assertion;
+  }
+  absl::flat_hash_set<std::string> created_assertions;
+  created_assertions.reserve(manifest.claim().created_assertions_size());
+  for (const auto& assertion : manifest.claim().created_assertions()) {
+    created_assertions.insert(std::string(Basename(assertion.url())));
+  }
+  bool is_first_action_assertion = true;
+  auto validate_actions_assertion = [&](absl::string_view label,
+                                        const auto& actions_assertion) -> bool {
+    if (!ValidateActions(actions_assertion, assertion_store,
+                         validation_tracker)) {
+      return false;
+    }
+    if (created_assertions.contains(label) &&
+        !ValidateFirstAction(label, actions_assertion,
+                             is_first_action_assertion, validation_tracker)) {
+      return false;
+    }
+    is_first_action_assertion = false;
+    return true;
+  };
+
+  for (const auto& assertion : manifest.assertions()) {
+    if (assertion.has_actions_v1() &&
+        !validate_actions_assertion(assertion.label(),
+                                    assertion.actions_v1())) {
+      return false;
+    }
+    if (assertion.has_actions() &&
+        !validate_actions_assertion(assertion.label(), assertion.actions())) {
+      return false;
+    }
+  }
+  return true;
+}
+
+}  // namespace credentio
diff --git a/assertion/actions_assertion_validator.h b/assertion/actions_assertion_validator.h
new file mode 100644
index 0000000..5e57523
--- /dev/null
+++ b/assertion/actions_assertion_validator.h
@@ -0,0 +1,34 @@
+// 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_ASSERTION_ACTIONS_ASSERTION_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_ASSERTION_ACTIONS_ASSERTION_VALIDATOR_H_
+
+#include "proto/actions_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+// Validator of actions assertions according to
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_actions_validation.
+class ActionsAssertionValidator {
+ public:
+  bool Validate(const Manifest& manifest,
+                ValidationTracker& validation_tracker) const;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASSERTION_ACTIONS_ASSERTION_VALIDATOR_H_
diff --git a/assertion/actions_assertion_validator_test.cc b/assertion/actions_assertion_validator_test.cc
new file mode 100644
index 0000000..3bd1bc2
--- /dev/null
+++ b/assertion/actions_assertion_validator_test.cc
@@ -0,0 +1,902 @@
+// 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 "assertion/actions_assertion_validator.h"
+
+#include <string>
+
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/manifest.pb.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::IsEmpty;
+using ::testing::TestWithParam;
+using ::testing::ValuesIn;
+
+// Returns the appropriate ingredient relationship for the given action.
+std::string IngredientRelation(absl::string_view action) {
+  return (action == "c2pa.placed" || action == "c2pa.removed") ? "componentOf"
+                                                               : "parentOf";
+}
+
+TEST(ActionsAssertionValidatorTest, Okay) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    claim {
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" }
+    }
+    assertions {
+      label: "c2pa.actions.v2"
+      actions { actions { action: "c2pa.created" } }
+    }
+  )pb");
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ActionsAssertionValidatorTest, OkayV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    claim {
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions" }
+    }
+    assertions {
+      label: "c2pa.actions"
+      actions_v1 { actions { action: "c2pa.created" } }
+    }
+  )pb");
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ActionsAssertionValidatorTest, NoActions) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions { actions {} }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, NoActionsV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions { actions_v1 {} }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, CreatedNotFirst) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions {
+      actions {
+        actions { action: "c2pa.drawing" }
+        actions { action: "c2pa.created" }
+      }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, CreatedNotFirstV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions {
+      actions_v1 {
+        actions { action: "c2pa.drawing" }
+        actions { action: "c2pa.created" }
+      }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, OpenedNotFirst) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions {
+      actions {
+        actions { action: "c2pa.drawing" }
+        actions { action: "c2pa.opened" }
+      }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, OpenedNotFirstV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions {
+      actions_v1 {
+        actions { action: "c2pa.drawing" }
+        actions { action: "c2pa.opened" }
+      }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, EmptyAction) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions { actions { actions { action: "" } } }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, EmptyActionV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions { actions_v1 { actions { action: "" } } }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+struct ActionParam {
+  std::string action_name;
+};
+
+using ValidateIngredientTest = TestWithParam<ActionParam>;
+TEST_P(ValidateIngredientTest, MissingParameters) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(
+      absl::StrFormat(R"pb(
+                        assertions { actions { actions { action: "%s" } } }
+                      )pb",
+                      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientTest, MissingParametersV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(
+      absl::StrFormat(R"pb(
+                        assertions { actions_v1 { actions { action: "%s" } } }
+                      )pb",
+                      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientTest, MissingIngredients) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest =
+      ParseTextProtoOrDie<Manifest>(absl::StrFormat(R"pb(
+                                                      assertions {
+                                                        actions {
+                                                          actions {
+                                                            action: "%s"
+                                                            parameters {}
+                                                          }
+                                                        }
+                                                      }
+                                                    )pb",
+                                                    GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientTest, MissingIngredientsV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest =
+      ParseTextProtoOrDie<Manifest>(absl::StrFormat(R"pb(
+                                                      assertions {
+                                                        actions_v1 {
+                                                          actions {
+                                                            action: "%s"
+                                                            parameters {}
+                                                          }
+                                                        }
+                                                      }
+                                                    )pb",
+                                                    GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientTest, EmptyIngredientUrl) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(
+      absl::StrFormat(R"pb(
+                        assertions {
+                          actions {
+                            actions {
+                              action: "%s"
+                              parameters { ingredients {} }
+                            }
+                          }
+                        }
+                      )pb",
+                      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientTest, EmptyIngredientUrlV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(
+      absl::StrFormat(R"pb(
+                        assertions {
+                          actions_v1 {
+                            actions {
+                              action: "%s"
+                              parameters { ingredients {} }
+                            }
+                          }
+                        }
+                      )pb",
+                      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientTest, MissingIngredientAssertion) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientTest, WrongAssertionType) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          actions {}
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientTest, NoC2paManifestInIngredientV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "%s" }
+        }
+      )pb",
+      GetParam().action_name, IngredientRelation(GetParam().action_name)));
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty());
+}
+
+TEST_P(ValidateIngredientTest, NoC2paManifestInIngredientV2) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "%s" }
+        }
+      )pb",
+      GetParam().action_name, IngredientRelation(GetParam().action_name)));
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty());
+}
+
+TEST_P(ValidateIngredientTest, NoC2paManifestInIngredientV3) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "%s" }
+        }
+      )pb",
+      GetParam().action_name, IngredientRelation(GetParam().action_name)));
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty());
+}
+
+constexpr ActionParam kActionParams[] = {
+    {.action_name = "c2pa.opened"},     {.action_name = "c2pa.placed"},
+    {.action_name = "c2pa.removed"},    {.action_name = "c2pa.repackaged"},
+    {.action_name = "c2pa.transcoded"},
+};
+INSTANTIATE_TEST_SUITE_P(ActionsAssertionValidatorTest, ValidateIngredientTest,
+                         ValuesIn(kActionParams));
+
+using ValidateIngredientParentOfTest = TestWithParam<ActionParam>;
+TEST_P(ValidateIngredientParentOfTest, Valid) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 {
+            relationship: "parentOf",
+            active_manifest {}
+          }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty());
+}
+
+TEST_P(ValidateIngredientParentOfTest, WrongRelationshipIngredientV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "childOf" }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientParentOfTest, WrongRelationshipIngredientV2) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "childOf" }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientParentOfTest, WrongRelationshipIngredientV3) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "childOf" }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+constexpr ActionParam kParentOfActionParams[] = {
+    {.action_name = "c2pa.opened"},
+    {.action_name = "c2pa.repackaged"},
+    {.action_name = "c2pa.transcoded"},
+};
+INSTANTIATE_TEST_SUITE_P(ActionsAssertionValidatorTest,
+                         ValidateIngredientParentOfTest,
+                         ValuesIn(kParentOfActionParams));
+
+using ValidateIngredientComponentOfTest = TestWithParam<ActionParam>;
+TEST_P(ValidateIngredientComponentOfTest, Valid) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 {
+            relationship: "componentOf",
+            active_manifest {}
+          }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty());
+}
+
+TEST_P(ValidateIngredientComponentOfTest, WrongRelationshipIngredientV1) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "parentOf" }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientComponentOfTest, WrongRelationshipIngredientV2) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "parentOf" }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+
+TEST_P(ValidateIngredientComponentOfTest, WrongRelationshipIngredientV3) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(absl::StrFormat(
+      R"pb(
+        assertions {
+          actions {
+            actions {
+              action: "%s"
+              parameters {
+                ingredients {
+                  url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                }
+              }
+            }
+          }
+        }
+        assertions {
+          label: "c2pa.ingredient.v3"
+          ingredient_v3 { relationship: "parentOf" }
+        }
+      )pb",
+      GetParam().action_name));
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionIngredientMismatch));
+}
+constexpr ActionParam kComponentOfActionParams[] = {
+    {.action_name = "c2pa.placed"},
+    {.action_name = "c2pa.removed"},
+};
+INSTANTIATE_TEST_SUITE_P(ActionsAssertionValidatorTest,
+                         ValidateIngredientComponentOfTest,
+                         ValuesIn(kComponentOfActionParams));
+
+TEST(ActionsAssertionValidatorTest, ValidateRedactedActionMissingRedactedUri) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions {
+      actions {
+        actions {
+          action: "c2pa.redacted"
+          parameters {}
+        }
+      }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionRedactionMismatch));
+}
+
+TEST(ActionsAssertionValidatorTest,
+     ValidateRedactedActionMissingRedactedAssertion) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions {
+      actions {
+        actions {
+          action: "c2pa.redacted"
+          parameters {
+            redacted_uri: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+          }
+        }
+      }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionRedactionMismatch));
+}
+
+TEST(ActionsAssertionValidatorTest, ValidateRedactedActionOkay) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    assertions {
+      actions {
+        actions {
+          action: "c2pa.redacted"
+          parameters {
+            redacted_uri: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+          }
+        }
+      }
+    }
+    assertions {
+      label: "c2pa.ingredient.v3"
+      ingredient_v3 {}
+    }
+  )pb");
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty());
+}
+
+TEST(ActionsAssertionValidatorTest, ValidCreatedAssertions) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    claim {
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" }
+      created_assertions {
+        url: "self#jumbf=c2pa.assertions/c2pa.actions.v2__1"
+      }
+    }
+    assertions {
+      label: "c2pa.actions.v2"
+      actions { actions { action: "c2pa.created" } }
+    }
+    assertions {
+      label: "c2pa.actions.v2__1"
+      actions { actions { action: "c2pa.resized" } }
+    }
+  )pb");
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ActionsAssertionValidatorTest, ValidOpenedAssertions) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    claim {
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" }
+      created_assertions {
+        url: "self#jumbf=c2pa.assertions/c2pa.actions.v2__1"
+      }
+    }
+    assertions {
+      label: "c2pa.actions.v2"
+      actions {
+        actions {
+          action: "c2pa.opened"
+          parameters {
+            ingredients { url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3" }
+          }
+        }
+      }
+    }
+    assertions {
+      label: "c2pa.actions.v2__1"
+      actions { actions { action: "c2pa.resized" } }
+    }
+    assertions {
+      label: "c2pa.ingredient.v3"
+      ingredient_v3 {
+        relationship: "parentOf",
+        active_manifest {}
+      }
+    }
+  )pb");
+  EXPECT_TRUE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ActionsAssertionValidatorTest, MalformedFirstAssertion) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    claim {
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" }
+    }
+    assertions {
+      label: "c2pa.actions.v2"
+      actions { actions { action: "c2pa.resized" } }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, MalformedCreatedAssertions) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    claim {
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions" }
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" }
+    }
+    assertions {
+      label: "c2pa.actions"
+      actions_v1 { actions { action: "c2pa.resized" } }
+    }
+    assertions {
+      label: "c2pa.actions.v2"
+      actions { actions { action: "c2pa.created" } }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, MalformedOpenedAssertions) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    claim {
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions" }
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" }
+    }
+    assertions {
+      label: "c2pa.actions.v2"
+      actions_v1 { actions { action: "c2pa.resized" } }
+    }
+    assertions {
+      label: "c2pa.actions"
+      actions { actions { action: "c2pa.opened" } }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+TEST(ActionsAssertionValidatorTest, MalformedDuplicatedCreatedAssertion) {
+  TestValidationTracker tracker;
+  ActionsAssertionValidator validator;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    claim {
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions" }
+      created_assertions { url: "self#jumbf=c2pa.assertions/c2pa.actions.v2" }
+    }
+    assertions {
+      label: "c2pa.actions.v2"
+      actions_v1 { actions { action: "c2pa.created" } }
+    }
+    assertions {
+      label: "c2pa.actions"
+      actions { actions { action: "c2pa.created" } }
+    }
+  )pb");
+  EXPECT_FALSE(validator.Validate(manifest, tracker.tracker()));
+  EXPECT_THAT(tracker.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionMalformed));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/assertion/assertion_encoder.cc b/assertion/assertion_encoder.cc
new file mode 100644
index 0000000..70b0524
--- /dev/null
+++ b/assertion/assertion_encoder.cc
@@ -0,0 +1,102 @@
+// 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 "assertion/assertion_encoder.h"
+
+#include <utility>
+
+#include "absl/base/no_destructor.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/string_view.h"
+#include "cbor/options.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.h"
+#include "proto/actions_assertion.cbor.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.cbor.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.cbor.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/collection_data_hash_assertion.cbor.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.cbor.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/ingredient_assertion.cbor.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/multi_asset_hash_assertion.cbor.h"
+
+namespace credentio {
+
+namespace {
+
+template <typename ProtoType>
+absl::StatusOr<absl::Cord> EncodeProtoToCbor(
+    const ProtoType& proto, const cbor::FromProtoOptions& options) {
+  ABSL_ASSIGN_OR_RETURN(auto cbor, cbor::FromProto(proto, options));
+  return absl::Cord(cbor->toString());
+}
+
+}  // namespace
+
+absl::StatusOr<absl::Cord> StandardAssertionEncoder::ToCbor(
+    const Assertion& assertion) const {
+  if (assertion.has_actions()) {
+    return EncodeProtoToCbor(assertion.actions(), options_);
+  }
+  if (assertion.has_bmff_based_hash()) {
+    return EncodeProtoToCbor(assertion.bmff_based_hash(), options_);
+  }
+  if (assertion.has_data_hash()) {
+    return EncodeProtoToCbor(assertion.data_hash(), options_);
+  }
+  if (assertion.has_collection_data_hash()) {
+    return EncodeProtoToCbor(assertion.collection_data_hash(), options_);
+  }
+  if (assertion.has_ingredient_v3()) {
+    return EncodeProtoToCbor(assertion.ingredient_v3(), options_);
+  }
+  if (assertion.has_boxes_hash()) {
+    return EncodeProtoToCbor(assertion.boxes_hash(), options_);
+  }
+  if (assertion.has_multi_asset_hash()) {
+    return EncodeProtoToCbor(assertion.multi_asset_hash(), options_);
+  }
+  return absl::InvalidArgumentError("Assertion not supported.");
+}
+
+absl::StatusOr<jumbf::BuiltSuperBox> AssertionEncoder::ToJumbf(
+    const Assertion& assertion) const {
+  ABSL_ASSIGN_OR_RETURN(absl::Cord cbor_data, ToCbor(assertion));
+  jumbf::SuperBoxBuilder assertion_builder(
+      jumbf::kCborBoxTypeUuid, {.label = absl::Cord(assertion.label())});
+  ABSL_RETURN_IF_ERROR(
+      assertion_builder.AddContent(jumbf::kCborBoxType, std::move(cbor_data)));
+  return std::move(assertion_builder).Finalize();  // NOLINT: rvalue method call
+}
+
+const AssertionEncoder& GetAssertionEncoder(
+    bool skip_validity_checks_for_test) {
+  static const absl::NoDestructor<StandardAssertionEncoder> encoder_for_test(
+      /*skip_validity_checks_for_test=*/true);
+  static const absl::NoDestructor<StandardAssertionEncoder> encoder(
+      /*skip_validity_checks_for_test=*/false);
+  return skip_validity_checks_for_test ? *encoder_for_test : *encoder;
+}
+
+}  // namespace credentio
diff --git a/assertion/assertion_encoder.h b/assertion/assertion_encoder.h
new file mode 100644
index 0000000..1a964eb
--- /dev/null
+++ b/assertion/assertion_encoder.h
@@ -0,0 +1,56 @@
+// 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_ASSERTION_ASSERTION_ENCODER_H_
+#define THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_ENCODER_H_
+
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "cbor/options.h"
+#include "jumbf/box_builder.h"
+#include "proto/assertion.pb.h"
+
+namespace credentio {
+
+// An encoder to serialize an Assertion's metadata to the CBOR or JUMBF format.
+class AssertionEncoder {
+ public:
+  virtual ~AssertionEncoder() = default;
+
+  virtual absl::StatusOr<absl::Cord> ToCbor(
+      const Assertion& assertion) const = 0;
+
+  virtual absl::StatusOr<jumbf::BuiltSuperBox> ToJumbf(
+      const Assertion& assertion) const;
+};
+
+class StandardAssertionEncoder : public AssertionEncoder {
+ public:
+  explicit StandardAssertionEncoder(bool skip_validity_checks_for_test = false)
+      : options_({.skip_validation = skip_validity_checks_for_test}) {}
+  ~StandardAssertionEncoder() override = default;
+
+  absl::StatusOr<absl::Cord> ToCbor(const Assertion& assertion) const override;
+
+ private:
+  cbor::FromProtoOptions options_;
+};
+
+// Returns a singleton StandardAssertionEncoder.
+const AssertionEncoder& GetAssertionEncoder(bool skip_validity_checks_for_test);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_ENCODER_H_
diff --git a/assertion/assertion_encoder_test.cc b/assertion/assertion_encoder_test.cc
new file mode 100644
index 0000000..46759a4
--- /dev/null
+++ b/assertion/assertion_encoder_test.cc
@@ -0,0 +1,391 @@
+// 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 "assertion/assertion_encoder.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_join.h"
+#include "absl/strings/string_view.h"
+#include "assertion/assertion_parser.h"
+#include "cbor/cbor.h"
+#include "cbor/parse.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/parse.h"
+#include "proto/actions_assertion.cbor.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.cbor.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.cbor.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.cbor.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/ingredient_assertion.cbor.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+
+namespace credentio {
+
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::cbor::Parse;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::HasSubstr;
+
+TEST(AssertionEncoderTest, UnsupportedAssertionToEncode) {
+  auto assertion = ParseTextProtoOrDie<Assertion>(R"pb(
+    label: "unsupported_assertion"
+  )pb");
+
+  StandardAssertionEncoder encoder;
+  EXPECT_THAT(encoder.ToCbor(assertion),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Assertion not supported.")));
+}
+
+TEST(AssertionEncoderTest, EncodeAction) {
+  auto assertion = ParseTextProtoOrDie<Assertion>(R"pb(
+    label: "c2pa.actions.v2"
+    actions {
+      all_actions_included: true
+      actions {
+        action: "c2pa.created"
+        digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+        software_agent {
+          name: "Joe\'s Photo Editor"
+          version: "2.0"
+          operating_system: "Windows 10"
+        }
+        parameters {
+          instance_id: "ed610ae51f604002be3dbf0c589a2f1f"
+          ingredients {
+            url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient__1"
+            algorithm: "sha256"
+            hash: "hash"
+          }
+          ingredients {
+            url: "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient__2"
+            algorithm: "sha256"
+            hash: "hash"
+          }
+        }
+      }
+      actions {
+        action: "c2pa.filtered"
+        software_agent {
+          name: "Joe\'s Photo Editor"
+          version: "2.0"
+          operating_system: "Windows 10"
+        }
+      }
+      actions { action: "c2pa.cropped", software_agent_index: 1 }
+      templates {
+        action: "com.joesphoto.filter"
+        description: "Magic Filter"
+        digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic"
+        software_agent { name: "Joe\'s Photo Editor" version: "2.0" }
+        icon { url: "http://cdn.examplephotoagency.com/logo.svg" hash: "hash" }
+      }
+      software_agents {
+        name: "Joe\'s Photo Editor"
+        version: "2.0"
+        operating_system: "Windows 10"
+      }
+      software_agents {
+        name: "Bob\'s Photo Editor"
+        version: "1.0"
+        operating_system: "Windows 10"
+      }
+    }
+  )pb");
+
+  StandardAssertionEncoder encoder;
+  auto cbor = encoder.ToCbor(assertion);
+  ABSL_ASSERT_OK(cbor);
+  std::string cbor_string(*cbor);
+  auto parse_result = Parse(cbor_string);
+  ABSL_ASSERT_OK(parse_result);
+  auto map = (*parse_result)->AsMap();
+  ABSL_ASSERT_OK(map);
+  credentio::ActionsAssertion result;
+  auto status = cbor::ToProto(*map, &result);
+  ABSL_ASSERT_OK(status);
+  EXPECT_THAT(result, EqualsProto(assertion.actions()));
+}
+
+TEST(AssertionEncoderTest, EncodeBmffBasedHash) {
+  auto assertion = ParseTextProtoOrDie<Assertion>(R"pb(
+    label: "c2pa.hash.bmff.v3"
+    bmff_based_hash {
+      hash: "Zm9v",
+      name: "Example `c2pa.hash.bmff.v2` assertion",
+      exclusions:
+      [ {
+        data:
+        [ { value: "Zm9v", offset: 8 }],
+        xpath: "/uuid"
+      }
+        , { xpath: "/ftyp" }
+        , { xpath: "/mfra" }
+        , { xpath: "/moov[1]/pssh" }
+        , {
+          data:
+          [ { value: "Zm9v", offset: 20 }],
+          xpath: "/emsg"
+        }]
+    }
+  )pb");
+
+  StandardAssertionEncoder encoder;
+  auto cbor = encoder.ToCbor(assertion);
+  ABSL_ASSERT_OK(cbor);
+  std::string cbor_string(*cbor);
+  auto parse_result = Parse(cbor_string);
+  ABSL_ASSERT_OK(parse_result);
+  auto map = (*parse_result)->AsMap();
+  ABSL_ASSERT_OK(map);
+  credentio::BmffBasedHashAssertion result;
+  auto status = cbor::ToProto(*map, &result);
+  ABSL_ASSERT_OK(status);
+  EXPECT_THAT(result, EqualsProto(assertion.bmff_based_hash()));
+}
+
+TEST(AssertionEncoderTest, EncodeDataHash) {
+  auto assertion = ParseTextProtoOrDie<Assertion>(R"pb(
+    label: "c2pa.hash.data"
+    data_hash {
+      alg: "sha256",
+      pad: "b64'Zm9v'",
+      hash: "b64'Zm9v'",
+      name: "JUMBF manifest",
+      exclusions:
+      [ { start: 9960, length: 4213 }
+        , { start: 14173, length: 1000 }]
+    }
+  )pb");
+
+  StandardAssertionEncoder encoder;
+  auto cbor = encoder.ToCbor(assertion);
+  ABSL_ASSERT_OK(cbor);
+  std::string cbor_string(*cbor);
+  auto parse_result = Parse(cbor_string);
+  ABSL_ASSERT_OK(parse_result);
+  auto map = (*parse_result)->AsMap();
+  ABSL_ASSERT_OK(map);
+  EXPECT_THAT(map->GetByteString("pad"), IsOk());
+  credentio::DataHashAssertion result;
+  auto status = cbor::ToProto(*map, &result);
+  ABSL_ASSERT_OK(status);
+
+  auto expected = assertion.data_hash();
+  expected.clear_pad();
+  auto actual = result;
+  actual.clear_pad();
+  EXPECT_THAT(actual, EqualsProto(expected));
+}
+
+TEST(AssertionEncoderTest, EncodeIngredientAssertion) {
+  auto assertion = ParseTextProtoOrDie<Assertion>(R"pb(
+    label: "c2pa.ingredient.v3"
+    ingredient_v3 {
+      title: "image 1.jpg",
+      media_type: "image/jpeg",
+      thumbnail: {
+        url: "self#jumbf=c2pa/urn:c2pa:F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4/c2pa.thumbnail.ingredient_1.jpg",
+        hash: "b64'UjRAYWiAq4lfCRDmksWAlDJN/XtHHFFwMWymsZsm3j8='"
+      },
+      relationship: "parentOf",
+      active_manifest: {
+        url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322",
+        hash: "b64'1kjJTO108b71cL95UxgfHD3eDgk9VrCedW8n3fYTRMk='"
+      },
+      claim_signature: {
+        url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature",
+        hash: "b64'85KAvU3+3YgtIjj6IV0fzKwj8si/85+gevVSK2Iw+S0='"
+      },
+      validation_results: {
+        active_manifest: {
+          successes:
+          [ {
+            code: "claimSignature.validated",
+            url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+          }
+            , {
+              code: "signingCredential.trusted",
+              url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+            }
+            , {
+              code: "timeStamp.validated",
+              url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+            }
+            , {
+              code: "timeStamp.trusted",
+              url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+            }
+            , {
+              code: "assertion.hashedURI.match",
+              url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3"
+            }],
+          informationals:
+          [ {
+            code: "signingCredential.ocsp.skipped",
+            url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+          }],
+          failures: []
+        },
+        ingredient_deltas:
+        [ {
+          ingredient_assertion_uri: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3",
+          validation_deltas: {
+            successes: [],
+            informationals: [],
+            failures:
+            [ {
+              code: "assertion.hashedURI.mismatch",
+              url: "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.metadata"
+            }]
+          }
+        }
+          , {
+            ingredient_assertion_uri: "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.ingredient.v3",
+            validation_deltas: {
+              successes: [],
+              informationals: [],
+              failures:
+              [ {
+                code: "signingCredential.untrusted",
+                url: "self#jumbf=c2pa/urn:c2pa:72C28A7C-7F5B-4301-B373-3183C10AF7C5/c2pa.signature"
+              }]
+            }
+          }]
+      }
+    }
+  )pb");
+
+  StandardAssertionEncoder encoder;
+  auto cbor = encoder.ToCbor(assertion);
+  ABSL_ASSERT_OK(cbor);
+  std::string cbor_string(*cbor);
+  auto parse_result = Parse(cbor_string);
+  ABSL_ASSERT_OK(parse_result);
+  auto map = (*parse_result)->AsMap();
+  ABSL_ASSERT_OK(map);
+  credentio::IngredientAssertionV3 result;
+  auto status = cbor::ToProto(*map, &result);
+  ABSL_ASSERT_OK(status);
+  EXPECT_THAT(result, EqualsProto(assertion.ingredient_v3()));
+}
+
+TEST(AssertionEncoderTest, EncodeBoxesHash) {
+  auto assertion = ParseTextProtoOrDie<Assertion>(R"pb(
+    label: "c2pa.hash.boxes"
+    boxes_hash {
+      alg: "sha256",
+      boxes:
+      [ { names: [ "SOI", "APP0", "APP2" ], hash: "b64'Zm9v'" }
+        , { names: [ "C2PA" ], hash: "b64'YmFy'" }]
+    }
+  )pb");
+
+  StandardAssertionEncoder encoder;
+  auto cbor = encoder.ToCbor(assertion);
+  ABSL_ASSERT_OK(cbor);
+  std::string cbor_string(*cbor);
+  auto parse_result = Parse(cbor_string);
+  ABSL_ASSERT_OK(parse_result);
+  auto map = (*parse_result)->AsMap();
+  ABSL_ASSERT_OK(map);
+  credentio::BoxesHashAssertion result;
+  auto status = cbor::ToProto(*map, &result);
+  ABSL_ASSERT_OK(status);
+  EXPECT_THAT(result, EqualsProto(assertion.boxes_hash()));
+}
+
+TEST(GetAssertionEncoderTest, ReturnsSameInstanceForSameParam) {
+  const auto& encoder1 = GetAssertionEncoder(true);
+  const auto& encoder2 = GetAssertionEncoder(true);
+  EXPECT_EQ(&encoder1, &encoder2);
+
+  const auto& encoder3 = GetAssertionEncoder(false);
+  const auto& encoder4 = GetAssertionEncoder(false);
+  EXPECT_EQ(&encoder3, &encoder4);
+}
+
+TEST(GetAssertionEncoderTest, ReturnsDifferentInstancesForDifferentParams) {
+  const auto& encoder1 = GetAssertionEncoder(true);
+  const auto& encoder2 = GetAssertionEncoder(false);
+  EXPECT_NE(&encoder1, &encoder2);
+}
+
+absl::StatusOr<Assertion> ParseAssertionBox(absl::string_view assertion_data) {
+  ABSL_ASSIGN_OR_RETURN(auto assertion_box,
+                        jumbf::ConsumeSuperBox(&assertion_data, 9));
+  if (assertion_box.contents.size() != 1) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "the content size is not 1, but ", assertion_box.contents.size()));
+  }
+  AssertionParser parser;
+  TestValidationTracker validation_tracker;
+  std::optional<Assertion> assertion =
+      parser.ParseCbor(assertion_box.description.label.value_or(""),
+                       assertion_box.contents[0].Get<jumbf::CborBox>().payload,
+                       validation_tracker.tracker());
+  if (!assertion.has_value()) {
+    return absl::InvalidArgumentError(
+        absl::StrJoin(validation_tracker.GetFailures(), ";"));
+  }
+  return assertion.value();
+}
+
+TEST(AssertionEncoderTest, ToJumbf) {
+  StandardAssertionEncoder encoder(
+      /*skip_validity_checks_for_test=*/false);
+  auto assertion = ParseTextProtoOrDie<Assertion>(R"pb(
+    label: "c2pa.actions.v2"
+    actions {
+      actions {
+        action: "c2pa.created"
+        software_agent { name: "test generator" }
+        description: "created for testing"
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+  )pb");
+  auto assertion_box = encoder.ToJumbf(assertion);
+  ABSL_ASSERT_OK(assertion_box);
+  auto serialized = std::move(*assertion_box).AsCord();
+
+  auto parsed_assertion = ParseAssertionBox(serialized.Flatten());
+  ABSL_ASSERT_OK(parsed_assertion);
+  EXPECT_THAT(*parsed_assertion, EqualsProto(assertion));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/assertion/assertion_parser.cc b/assertion/assertion_parser.cc
new file mode 100644
index 0000000..a42c4ad
--- /dev/null
+++ b/assertion/assertion_parser.cc
@@ -0,0 +1,204 @@
+// 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 "assertion/assertion_parser.h"
+
+#include <optional>
+
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "assertion/parse_assertion.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "google/protobuf/struct.pb.h"
+#include "proto/assertion.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+namespace {
+void RecordAssertionParsingFailure(absl::string_view label, absl::Status error,
+                                   ValidationTracker& validation_tracker) {
+  validation_tracker.RecordFailure(
+      FailureStatusCode::kAssertionCborInvalid,
+      {.url =
+           absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, label),
+       .explanation = absl::StrCat("Failed to parse the assertion with label: ",
+                                   label, " and error: ", error.message())});
+}
+
+bool IsBmffBasedHashLabel(absl::string_view label) {
+  return label == kBmffBasedHashAssertionV2Label ||
+         label == kBmffBasedHashAssertionV3Label ||
+         label == kBmffBasedHashAssertionV2PartLabel ||
+         label == kBmffBasedHashAssertionV3PartLabel;
+}
+
+bool IsDataHashLabel(absl::string_view label) {
+  return label == kDataHashAssertionLabel ||
+         label == kDataHashAssertionPartLabel;
+}
+
+bool IsUnsupportedIngredientLabel(absl::string_view label) {
+  return label == kIngredientAssertionV1Label ||
+         label == kIngredientAssertionV2Label;
+}
+
+bool IsBoxesHashLabel(absl::string_view label) {
+  return label == kBoxesHashAssertionLabel ||
+         label == kBoxesHashAssertionPartLabel;
+}
+
+bool IsCollectionDataHashLabel(absl::string_view label) {
+  return label == kCollectionDataHashAssertionLabel ||
+         label == kCollectionDataHashAssertionPartLabel;
+}
+
+}  // namespace
+
+absl::string_view StripMultipleInstanceSuffix(absl::string_view label) {
+  return label.substr(0, label.find("__"));
+}
+
+bool AssertionTypeMatcher::Matches(absl::string_view label) const {
+  return StripMultipleInstanceSuffix(label) == label_;
+}
+
+std::optional<Assertion> AssertionParser::ParseCbor(
+    absl::string_view label, absl::string_view content,
+    ValidationTracker& validation_tracker) const {
+  absl::string_view stripped_label = StripMultipleInstanceSuffix(label);
+  if (IsUnsupportedIngredientLabel(stripped_label)) {
+    // Unlike other assertion types, it's not safe to ignore an unsupported
+    // ingredient assertion.
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kGoogleUnsupportedSpecVersion,
+        {.url =
+             absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, label),
+         .explanation = "c2pa.ingredient (v1) and c2pa.ingredient.v2 "
+                        "assertions are not supported"});
+    return std::nullopt;
+  }
+
+  Assertion assertion;
+  assertion.set_label(label);
+  absl::Status status;
+
+  if (stripped_label == kActionsAssertionV2Label) {
+    status = ParseAssertion(content, assertion.mutable_actions());
+  } else if (stripped_label == kActionsAssertionV1Label) {
+    status = ParseAssertion(content, assertion.mutable_actions_v1());
+  } else if (IsBmffBasedHashLabel(stripped_label)) {
+    status = ParseAssertion(content, assertion.mutable_bmff_based_hash());
+  } else if (IsDataHashLabel(stripped_label)) {
+    status = ParseAssertion(content, assertion.mutable_data_hash());
+  } else if (stripped_label == kIngredientAssertionV3Label) {
+    status = ParseAssertion(content, assertion.mutable_ingredient_v3());
+  } else if (IsBoxesHashLabel(stripped_label)) {
+    status = ParseAssertion(content, assertion.mutable_boxes_hash());
+  } else if (stripped_label == kMultiAssetHashAssertionLabel) {
+    status = ParseAssertion(content, assertion.mutable_multi_asset_hash());
+  } else if (IsCollectionDataHashLabel(stripped_label)) {
+    status = ParseAssertion(content, assertion.mutable_collection_data_hash());
+  } else if (stripped_label == kAiDisclosureAssertionLabel) {
+    status = ParseAssertion(content, assertion.mutable_ai_disclosure());
+  } else if (stripped_label == kEnvironmentalSustainabilityAssertionLabel) {
+    status = ParseAssertion(content,
+                            assertion.mutable_environmental_sustainability());
+  } else if (stripped_label == kSoftBindingAssertionLabel) {
+    status = ParseAssertion(content, assertion.mutable_soft_binding());
+  } else {
+    return std::nullopt;
+  }
+
+  if (!status.ok()) {
+    RecordAssertionParsingFailure(label, status, validation_tracker);
+    return std::nullopt;
+  }
+  return assertion;
+}
+
+std::optional<Assertion> AssertionParser::ParseThumbnail(
+    absl::string_view label, absl::string_view media_type,
+    absl::string_view data, std::optional<absl::string_view> file_name,
+    ValidationTracker& validation_tracker) const {
+  Assertion assertion;
+  assertion.set_label(label);
+  if (absl::StartsWith(label, kThumbnailClaimAssertionLabelPrefix)) {
+    *assertion.mutable_claim_thumbnail() =
+        ParseThumbnailAssertion(media_type, data, file_name);
+    return assertion;
+  }
+  if (absl::StartsWith(label, kThumbnailIngredientAssertionLabelPrefix)) {
+    *assertion.mutable_ingredient_thumbnail() =
+        ParseThumbnailAssertion(media_type, data, file_name);
+    return assertion;
+  }
+  return std::nullopt;
+}
+
+std::optional<Assertion> AssertionParser::ParseMetadata(
+    absl::string_view label, absl::string_view content,
+    ValidationTracker& validation_tracker) const {
+  Assertion assertion;
+  assertion.set_label(label);
+  absl::string_view stripped_label = StripMultipleInstanceSuffix(label);
+
+  if (stripped_label == kRepositoryReceiptAssertionLabel) {
+    if (auto status =
+            ParseAssertion(content, assertion.mutable_repository_receipt());
+        !status.ok()) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionJsonInvalid,
+          {.url = absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter,
+                               label),
+           .explanation =
+               absl::StrCat("Failed to parse the assertion with label: ", label,
+                            " and error: ", status.message())});
+      return std::nullopt;
+    }
+    return assertion;
+  }
+
+  if (!absl::EndsWith(stripped_label, kMetadataAssertionLabelSuffix)) {
+    return std::nullopt;
+  }
+  if (auto status =
+          ParseStruct(content, assertion.mutable_metadata()->mutable_value());
+      !status.ok()) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionJsonInvalid,
+        {.url =
+             absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, label),
+         .explanation =
+             absl::StrCat("Failed to parse the assertion with label: ", label,
+                          " and error: ", status.message())});
+    return std::nullopt;
+  }
+  if (!assertion.metadata().value().fields().contains("@context")) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kGeneralError,
+        {.url =
+             absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter, label),
+         .explanation = "Metadata doesn't have @context"});
+    return std::nullopt;
+  }
+  return assertion;
+}
+
+}  // namespace credentio
diff --git a/assertion/assertion_parser.h b/assertion/assertion_parser.h
new file mode 100644
index 0000000..8ffedc4
--- /dev/null
+++ b/assertion/assertion_parser.h
@@ -0,0 +1,62 @@
+// 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_ASSERTION_ASSERTION_PARSER_H_
+#define THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_PARSER_H_
+
+#include <optional>
+#include <string>
+
+#include "absl/strings/string_view.h"
+#include "proto/assertion.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+// Returns the label with any multiple instance suffix (e.g., `__1`) removed.
+absl::string_view StripMultipleInstanceSuffix(absl::string_view label);
+
+// A helper class to match an assertion type (e.g., c2pa.actions.v2).
+class AssertionTypeMatcher {
+ public:
+  explicit AssertionTypeMatcher(absl::string_view label) : label_(label) {}
+  bool Matches(absl::string_view label) const;
+
+ private:
+  const std::string label_;
+};
+
+class AssertionParser {
+ public:
+  AssertionParser() = default;
+  // Parses the given assertion content into an Assertion proto.
+  std::optional<Assertion> ParseCbor(
+      absl::string_view label, absl::string_view content,
+      ValidationTracker& validation_tracker) const;
+
+  // Parses the given thumbnail assertion into an Assertion proto.
+  std::optional<Assertion> ParseThumbnail(
+      absl::string_view label, absl::string_view media_type,
+      absl::string_view data, std::optional<absl::string_view> file_name,
+      ValidationTracker& validation_tracker) const;
+
+  std::optional<Assertion> ParseMetadata(
+      absl::string_view label, absl::string_view content,
+      ValidationTracker& validation_tracker) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_PARSER_H_
diff --git a/assertion/assertion_parser_test.cc b/assertion/assertion_parser_test.cc
new file mode 100644
index 0000000..9ded34e
--- /dev/null
+++ b/assertion/assertion_parser_test.cc
@@ -0,0 +1,1071 @@
+// 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 "assertion/assertion_parser.h"
+
+#include <optional>
+#include <string>
+
+#include "absl/strings/match.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/assertion.pb.h"
+#include "testing/cbor_utils.h"
+#include "testing/jumbf_utils.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::cbor::FromJson;
+using ::credentio::CreateActionsAssertionBoxFromCbor;
+using ::credentio::CreateAssertionBoxFromCbor;
+using ::credentio::CreateBmffBasedHashAssertionBoxFromCbor;
+using ::credentio::CreateDataHashAssertionBoxFromCbor;
+using ::credentio::StripMultipleInstanceSuffix;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::Eq;
+using ::testing::IsEmpty;
+using ::testing::Optional;
+
+MATCHER_P2(FailureStatusIs, code, explanation, "") {
+  return arg.code() == absl::StrCat(code) &&
+         absl::StrContains(arg.explanation(), explanation);
+}
+
+class AssertionParserTest : public ::testing::Test {
+ protected:
+  AssertionParserTest() = default;
+  credentio::AssertionParser parser_;
+};
+
+TEST_F(AssertionParserTest, ParseActionsAssertion) {
+  auto cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created",
+          "softwareAgent": {
+              "name": "Joe's Photo Editor",
+              "version": "2.0",
+              "operating_system": "Windows 10"
+          },
+          "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+          "parameters": {
+            "instanceID": "ed610ae51f604002be3dbf0c589a2f1f",
+            "ingredients" : [
+              {
+                "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient__1",
+                "alg": "sha256",
+                "hash" : "b64'aGFzaA=='"
+              },
+              {
+                "url": "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient__2",
+                "alg": "sha256",
+                "hash" : "b64'aGFzaA=='"
+              }
+            ]
+          }
+        },
+        {
+          "action": "c2pa.filtered",
+          "softwareAgent" : {
+              "name": "Joe's Photo Editor",
+              "version": "2.0",
+              "operating_system": "Windows 10"
+          }
+        },
+        {
+          "action": "c2pa.cropped",
+          "softwareAgentIndex": 1
+        }
+      ],
+      "templates": [{
+        "action": "com.joesphoto.filter",
+        "description": "Magic Filter",
+        "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic",
+        "softwareAgent" : {
+            "name": "Joe's Photo Editor",
+            "version": "2.0",
+            "schema.org.SoftwareApplication.operatingSystem": "Windows 10"
+        },
+        "icon": {
+          "url": "http://cdn.examplephotoagency.com/logo.svg",
+          "hash": "b64'aGFzaA=='"
+        }
+      }],
+      "metadata": {
+        "reviewRatings": [
+          {
+            "value": 1,
+            "explanation": "Content bindings did not validate"
+          }
+        ],
+        "reference": {
+          "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.actions__1",
+          "alg": "sha256",
+          "hash": "b64'aGFzaA=='"
+        }
+      },
+      "softwareAgents": [
+        {
+          "name": "Joe's Photo Editor",
+          "version": "2.0",
+          "operating_system": "Windows 10"
+        },
+        {
+          "name": "Bob's Photo Editor",
+          "version": "1.0",
+          "operating_system": "Windows 10"
+        }
+      ]
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+    label: "c2pa.actions.v2"
+    actions {
+      actions {
+        action: "c2pa.created"
+        digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+        software_agent {
+          name: "Joe\'s Photo Editor"
+          version: "2.0"
+          operating_system: "Windows 10"
+        }
+        parameters {
+          instance_id: "ed610ae51f604002be3dbf0c589a2f1f"
+          ingredients {
+            url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient__1"
+            algorithm: "sha256"
+            hash: "hash"
+          }
+          ingredients {
+            url: "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient__2"
+            algorithm: "sha256"
+            hash: "hash"
+          }
+        }
+      }
+      actions {
+        action: "c2pa.filtered"
+        software_agent {
+          name: "Joe\'s Photo Editor"
+          version: "2.0"
+          operating_system: "Windows 10"
+        }
+      }
+      actions { action: "c2pa.cropped", software_agent_index: 1 }
+      templates {
+        action: "com.joesphoto.filter"
+        description: "Magic Filter"
+        digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic"
+        software_agent { name: "Joe\'s Photo Editor" version: "2.0" }
+        icon { url: "http://cdn.examplephotoagency.com/logo.svg" hash: "hash" }
+      }
+      software_agents {
+        name: "Joe\'s Photo Editor"
+        version: "2.0"
+        operating_system: "Windows 10"
+      }
+      software_agents {
+        name: "Bob\'s Photo Editor"
+        version: "1.0"
+        operating_system: "Windows 10"
+      }
+    }
+  )pb");
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(parser_.ParseCbor("c2pa.actions.v2", cbor, t.tracker()),
+              Optional(EqualsProto(expected)));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseBmffBasedHashAssertion) {
+  credentio::TestValidationTracker t;
+  std::string bmff_based_hash_assertion_cbor = FromJson(R"json(
+    {
+      "hash": "b64'Zm9v'",
+      "name": "Example `c2pa.hash.bmff.v2` assertion",
+      "exclusions": [
+          {
+            "data": [{ "value": "b64'Zm9v'", "offset": 8 }],
+            "xpath": "/uuid"
+          },
+          { "xpath": "/ftyp" },
+          { "xpath": "/mfra" },
+          { "xpath": "/moov[1]/pssh" },
+          {
+            "data": [{ "value": "b64'Zm9v'", "offset": 20 }],
+            "xpath": "/emsg"
+          }
+        ]
+    })json");
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.hash.bmff.v2", bmff_based_hash_assertion_cbor,
+                        t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.hash.bmff.v2"
+        bmff_based_hash {
+          hash: "foo"
+          name: "Example `c2pa.hash.bmff.v2` assertion"
+          exclusions {
+            data { value: "foo" offset: 8 }
+            xpath: "/uuid"
+          }
+          exclusions { xpath: "/ftyp" }
+          exclusions { xpath: "/mfra" }
+          exclusions { xpath: "/moov[1]/pssh" }
+          exclusions {
+            data { value: "foo" offset: 20 }
+            xpath: "/emsg"
+          }
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseDataHashAssertion) {
+  credentio::TestValidationTracker t;
+  std::string data_hash_assertion_cbor = FromJson(R"json(
+    {
+      "alg": "sha256",
+      "pad": "b64'Zm9v'",
+      "hash": "b64'Zm9v'",
+      "name": "JUMBF manifest",
+      "exclusions": [
+        {
+          "start": 9960,
+          "length": 4213
+        },
+        {
+          "start": 14173,
+          "length": 1000
+        }
+      ]
+    })json");
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.hash.data", data_hash_assertion_cbor,
+                        t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {
+          exclusions { start: 9960 length: 4213 }
+          exclusions { start: 14173 length: 1000 }
+          alg: "sha256"
+          hash: "foo"
+          name: "JUMBF manifest"
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseAiDisclosureAssertion) {
+  credentio::TestValidationTracker t;
+  std::string ai_disclosure_cbor = FromJson(R"json(
+    {
+      "modelType": "c2pa.types.model.onnx",
+      "modelName": "My Awesome AI Model",
+      "modelIdentifier": "https://example.com/models/awesome-ai-v1",
+      "contentProfile": {
+        "humanOversightLevel": "prompt_guided",
+        "scientificDomain": ["cs.AI"]
+      }
+    })json");
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.ai-disclosure", ai_disclosure_cbor, t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.ai-disclosure"
+        ai_disclosure {
+          model_type: "c2pa.types.model.onnx"
+          model_name: "My Awesome AI Model"
+          model_identifier: "https://example.com/models/awesome-ai-v1"
+          content_profile {
+            human_oversight_level: "prompt_guided"
+            scientific_domain: "cs.AI"
+          }
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseEnvironmentalSustainabilityAssertion) {
+  credentio::TestValidationTracker t;
+  std::string environmental_sustainability_cbor = FromJson(R"json(
+    {
+      "energy_kwh": {
+        "value": 1.23,
+        "measurementMethod": "com.example.direct-measurement"
+      },
+      "carbon_kgco2e": {
+        "value": 0.456
+      },
+      "water_litres": {
+        "value": 7.89,
+        "measurementMethod": "com.example.estimation"
+      }
+    })json");
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.environmental-sustainability",
+                        environmental_sustainability_cbor, t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.environmental-sustainability"
+        environmental_sustainability {
+          energy_kwh {
+            value: 1.23
+            measurement_method: "com.example.direct-measurement"
+          }
+          carbon_kgco2e { value: 0.456 }
+          water_litres {
+            value: 7.89
+            measurement_method: "com.example.estimation"
+          }
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseSoftBindingAssertion) {
+  credentio::TestValidationTracker t;
+  std::string soft_binding_assertion_cbor = FromJson(R"json(
+    {
+      "alg": "soft-algorithm",
+      "name": "soft-binding-name",
+      "alg-params": "b64'cGFyYW1z'",
+      "blocks": [
+        {
+          "scope": {
+            "extent": "b64'ZXh0ZW50'",
+            "timespan": {
+              "start": 100,
+              "end": 200
+            }
+          },
+          "value": "b64'dmFsdWU='"
+        }
+      ],
+      "bindingMetadata": {
+        "description": "metadata-desc",
+        "contact": "metadata-contact",
+        "informationalUrl": "metadata-url"
+      }
+    })json");
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.soft-binding", soft_binding_assertion_cbor,
+                        t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.soft-binding"
+        soft_binding {
+          algorithm: "soft-algorithm"
+          name: "soft-binding-name"
+          alg_params: "params"
+          blocks {
+            scope {
+              extent: "extent"
+              timespan { start: 100 end: 200 }
+            }
+            value: "value"
+          }
+          binding_metadata {
+            description: "metadata-desc"
+            contact: "metadata-contact"
+            informational_url: "metadata-url"
+          }
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseInvalidSoftBindingAssertion) {
+  // Soft binding assertion is missing the `alg` field.
+  std::string soft_binding_assertion_cbor = FromJson(R"json(
+    {
+      "name": "soft-binding-name"
+    })json");
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(parser_.ParseCbor("c2pa.soft-binding",
+                                soft_binding_assertion_cbor, t.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t.GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kAssertionCborInvalid,
+                  "c2pa.soft-binding")));
+}
+
+TEST_F(AssertionParserTest, ParseIngredientAssertionV2Unsupported) {
+  credentio::TestValidationTracker t;
+  std::string ingredient_0_assertion_cbor = FromJson(R"json(
+          {
+            "dc:title": "ingredient-title",
+            "dc:format": "ingredient-format",
+            "relationship": "ingredient-relationship",
+            "documentID": "ingredient-document-id",
+            "instanceID": "ingredient-instance-id",
+            "data": {
+              "url": "ingredient-data-url",
+              "alg": "ingredient-data-alg",
+              "hash": "b64'aGFzaA=='",
+              "size": 1234567890
+            },
+            "c2pa_manifest": {
+              "url": "ingredient-manifest-url",
+              "alg": "ingredient-manifest-alg",
+              "hash": "b64'aGFzaA=='"
+            },
+            "thumbnail": {
+              "url": "ingredient-thumbnail-url",
+              "alg": "ingredient-thumbnail-alg",
+              "hash": "b64'aGFzaA=='"
+            },
+            "validationStatus": [
+              {
+                "code": "status.1",
+                "url": "url.1",
+                "explanation": "explanation.1"
+              },
+              {
+                "code": "status.2",
+                "url": "url.2",
+                "explanation": "explanation.2"
+              }
+            ],
+            "description": "ingredient-description",
+            "informational_URI": "ingredient-informational-uri",
+            "metadata": {
+              "reviewRatings": [
+                {
+                  "value": 1,
+                  "explanation": "can't dance to it"
+                }
+              ]
+            }
+          })json");
+  EXPECT_THAT(parser_.ParseCbor("c2pa.ingredient.v2",
+                                ingredient_0_assertion_cbor, t.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t.tracker().GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kGoogleUnsupportedSpecVersion,
+                  "c2pa.ingredient.v2")));
+}
+
+TEST_F(AssertionParserTest, ParseIngredientAssertionV3) {
+  credentio::TestValidationTracker t;
+  std::string ingredient_0_assertion_cbor = FromJson(R"json(
+          {
+            "dc:title": "image 1.jpg",
+            "metadata": {
+              "reviewRatings": [
+                {
+                  "value": 5,
+                  "explanation": "Content bindings validated"
+                }
+              ]
+            },
+            "dc:format": "image/jpeg",
+            "thumbnail": {
+                "url": "self#jumbf=c2pa/urn:c2pa:F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4/c2pa.thumbnail.ingredient_1.jpg",
+                "hash": "b64'UjRAYWiAq4lfCRDmksWAlDJN/XtHHFFwMWymsZsm3j8='"
+            },
+            "relationship": "parentOf",
+            "activeManifest": {
+                "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322",
+                "hash": "b64'1kjJTO108b71cL95UxgfHD3eDgk9VrCedW8n3fYTRMk='"
+            },
+            "claimSignature": {
+                "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature",
+                "hash": "b64'85KAvU3+3YgtIjj6IV0fzKwj8si/85+gevVSK2Iw+S0='"
+            },
+            "validationResults": {
+              "activeManifest": {
+                "success": [
+                {
+                  "code": "claimSignature.validated",
+                  "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+                },
+                {
+                  "code": "signingCredential.trusted",
+                  "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+                },
+                {
+                  "code": "timeStamp.validated",
+                  "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+                },
+                {
+                  "code": "timeStamp.trusted",
+                  "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+                },
+                {
+                  "code": "assertion.hashedURI.match",
+                  "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3"
+                }
+                ],
+                "informational": [{
+                  "code": "signingCredential.ocsp.skipped",
+                  "url": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+                }],
+                "failure": []
+              },
+              "ingredientDeltas": [
+                {
+                  "ingredientAssertionURI": "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3",
+                  "validationDeltas": {
+                    "success": [],
+                    "informational": [],
+                    "failure": [{
+                      "code": "assertion.hashedURI.mismatch",
+                      "url": "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.metadata"
+                    }]
+                  }
+                },
+                {
+                  "ingredientAssertionURI": "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.ingredient.v3",
+                  "validationDeltas": {
+                    "success": [],
+                    "informational": [],
+                    "failure": [{
+                      "code": "signingCredential.untrusted",
+                      "url": "self#jumbf=c2pa/urn:c2pa:72C28A7C-7F5B-4301-B373-3183C10AF7C5/c2pa.signature"
+                    }]
+                  }
+                }
+              ]
+            }
+          })json");
+  auto result = parser_.ParseCbor("c2pa.ingredient.v3",
+                                  ingredient_0_assertion_cbor, t.tracker());
+  ASSERT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(
+      result,
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          title: "image 1.jpg"
+          media_type: "image/jpeg"
+          relationship: "parentOf"
+          active_manifest {
+            url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322"
+            hash: "\326H\311L\355t\361\276\365p\277yS\030\037\034=\336\016\t=V\260\236uo\'\335\366\023D\311"
+          }
+          claim_signature {
+            url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+            hash: "\363\222\200\275M\376\335\210-\"8\372!]\037\314\254#\362\310\277\363\237\240z\365R+b0\371-"
+          }
+          thumbnail {
+            url: "self#jumbf=c2pa/urn:c2pa:F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4/c2pa.thumbnail.ingredient_1.jpg"
+            hash: "R4@ah\200\253\211_\t\020\346\222\305\200\2242M\375{G\034Qp1l\246\261\233&\336?"
+          }
+          validation_results {
+            active_manifest {
+              successes {
+                code: "claimSignature.validated"
+                url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+              }
+              successes {
+                code: "signingCredential.trusted"
+                url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+              }
+              successes {
+                code: "timeStamp.validated"
+                url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+              }
+              successes {
+                code: "timeStamp.trusted"
+                url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+              }
+              successes {
+                code: "assertion.hashedURI.match"
+                url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3"
+              }
+              informationals {
+                code: "signingCredential.ocsp.skipped"
+                url: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature"
+              }
+            }
+            ingredient_deltas {
+              ingredient_assertion_uri: "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3"
+              validation_deltas {
+                failures {
+                  code: "assertion.hashedURI.mismatch"
+                  url: "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.metadata"
+                }
+              }
+            }
+            ingredient_deltas {
+              ingredient_assertion_uri: "self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.ingredient.v3"
+              validation_deltas {
+                failures {
+                  code: "signingCredential.untrusted"
+                  url: "self#jumbf=c2pa/urn:c2pa:72C28A7C-7F5B-4301-B373-3183C10AF7C5/c2pa.signature"
+                }
+              }
+            }
+          }
+        }
+      )pb"))));
+}
+
+TEST_F(AssertionParserTest, ParseBoxesHashAssertion) {
+  credentio::TestValidationTracker t;
+  std::string boxes_hash_assertion_cbor = FromJson(R"json(
+          {
+            "alg" : "sha256",
+            "boxes": [
+              {
+                "names" : ["SOI", "APP0", "APP2"],
+                "hash" : "b64'Zm9v'"
+              },
+              {
+                "names" : ["C2PA"],
+                "hash" : "b64'YmFy'"
+              }
+            ]
+          })json");
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.hash.boxes", boxes_hash_assertion_cbor,
+                        t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.hash.boxes"
+        boxes_hash {
+          alg: "sha256"
+          boxes { names: "SOI" names: "APP0" names: "APP2" hash: "foo" }
+          boxes { names: "C2PA" hash: "bar" }
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseThumbnailClaimAssertion) {
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseThumbnail("c2pa.thumbnail.claim.png", "image/png", "foo",
+                             "foo.png", t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.thumbnail.claim.png"
+        claim_thumbnail {
+          media_type: "image/png"
+          data: "foo"
+          file_name: "foo.png"
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseThumbnailIngredientAssertion) {
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseThumbnail("c2pa.thumbnail.ingredient.png", "image/png",
+                             "foo", "foo.png", t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.thumbnail.ingredient.png"
+        ingredient_thumbnail {
+          media_type: "image/png"
+          data: "foo"
+          file_name: "foo.png"
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseThumbnailAssertionUnrecognizedLabelIgnored) {
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseThumbnail("c2pa.thumbnail.something.png", "image/png", "foo",
+                             std::nullopt, t.tracker()),
+      Eq(std::nullopt));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseMetadataAssertion) {
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseMetadata("c2pa.metadata",
+                            R"json({
+                                      "@context": "bar",
+                                      "foo": "baz"
+                                    })json",
+                            t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.metadata"
+        metadata {
+          value {
+            fields {
+              key: "@context"
+              value { string_value: "bar" }
+            }
+            fields {
+              key: "foo"
+              value { string_value: "baz" }
+            }
+          }
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseMetadataAssertionCustomLabel) {
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseMetadata("com.litware.metadata",
+                            R"json({
+                                      "@context": "bar",
+                                      "foo": "baz"
+                                    })json",
+                            t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "com.litware.metadata"
+        metadata {
+          value {
+            fields {
+              key: "@context"
+              value { string_value: "bar" }
+            }
+            fields {
+              key: "foo"
+              value { string_value: "baz" }
+            }
+          }
+        }
+      )pb"))));
+  EXPECT_THAT(t.tracker().GetFailureStatuses(), IsEmpty());
+}
+
+TEST_F(AssertionParserTest, ParseMetadataAssertionMissingContext) {
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(parser_.ParseMetadata("c2pa.metadata",
+                                    R"json({
+                                      "@something": "bar"
+                                    })json",
+                                    t.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t.tracker().GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kGeneralError, "@context")));
+}
+
+TEST_F(AssertionParserTest, ParseMetadataAssertionInvalidJson) {
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(parser_.ParseMetadata("c2pa.metadata",
+                                    R"json({
+                                      "foo":
+                                    })json",
+                                    t.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t.tracker().GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kAssertionJsonInvalid,
+                  "c2pa.metadata")));
+}
+
+TEST_F(AssertionParserTest, ParseRepositoryReceiptAssertion) {
+  credentio::TestValidationTracker t;
+  std::string repository_receipt_json = R"json(
+    {
+      "repository": {
+        "uri": "https://repository.example.com/manifests/xyz123",
+        "manifestId": "urn:uuid:12345678-1234-5678-1234-567812345678"
+      },
+      "anchor": {
+        "uri": "https://anchor.example.com/receipts/9876",
+        "parameters": {
+          "version": "1.0"
+        },
+        "proof": {
+          "signature": "c2lnbmF0dXJl"
+        }
+      }
+    })json";
+  EXPECT_THAT(
+      parser_.ParseMetadata("c2pa.repository-receipt", repository_receipt_json,
+                            t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.repository-receipt"
+        repository_receipt {
+          repository {
+            uri: "https://repository.example.com/manifests/xyz123"
+            manifest_id: "urn:uuid:12345678-1234-5678-1234-567812345678"
+          }
+          anchor {
+            uri: "https://anchor.example.com/receipts/9876"
+            parameters {
+              fields {
+                key: "version"
+                value { string_value: "1.0" }
+              }
+            }
+            proof {
+              fields {
+                key: "signature"
+                value { string_value: "c2lnbmF0dXJl" }
+              }
+            }
+          }
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseInvalidActionsAssertion) {
+  // Actions assertion is missing the `action` field.
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+        }
+      ]
+    })json");
+  auto actions_assertion_box =
+      CreateActionsAssertionBoxFromCbor(actions_assertion_cbor);
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.actions.v2", actions_assertion_cbor, t.tracker()),
+      Eq(std::nullopt));
+  EXPECT_THAT(t.GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kAssertionCborInvalid,
+                  "c2pa.actions.v2")));
+}
+
+TEST_F(AssertionParserTest, ParseInvalidBmffBasedHashAssertion) {
+  // `xpath` field is missing in the exclusion.
+  std::string bmff_based_hash_assertion_cbor = FromJson(R"json(
+        { "exclusions": [{}] }
+    )json");
+  auto bmff_based_hash_assertion_box =
+      CreateBmffBasedHashAssertionBoxFromCbor(bmff_based_hash_assertion_cbor);
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(parser_.ParseCbor("c2pa.hash.bmff.v2",
+                                bmff_based_hash_assertion_cbor, t.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t.GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kAssertionCborInvalid,
+                  "c2pa.hash.bmff.v2")));
+}
+
+TEST_F(AssertionParserTest, ParseInvalidDataHashAssertion) {
+  // Data hash assertion is missing the `hash` field.
+  std::string data_hash_assertion_cbor = FromJson(R"json({})json");
+  auto data_hash_assertion_box =
+      CreateDataHashAssertionBoxFromCbor(data_hash_assertion_cbor);
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(parser_.ParseCbor("c2pa.hash.data", data_hash_assertion_cbor,
+                                t.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t.GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kAssertionCborInvalid,
+                  "c2pa.hash.data")));
+}
+
+TEST_F(AssertionParserTest, ParseAssertionWithUnsupportedLabel) {
+  std::string actions_assertion_cbor = FromJson(R"json({})json");
+  auto actions_assertion_box =
+      CreateAssertionBoxFromCbor("random_label", actions_assertion_cbor);
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseCbor("random_label", actions_assertion_cbor, t.tracker()),
+      Eq(std::nullopt));
+  // Skip recording failures for now.
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+}
+
+TEST_F(AssertionParserTest, ParseIngredientAssertionV1Unsupported) {
+  credentio::TestValidationTracker t;
+  std::string ingredient_0_assertion_cbor = FromJson(R"json(
+          {
+            "dc:title": "ingredient-title",
+            "dc:format": "ingredient-format",
+            "documentID": "ingredient-document-id",
+            "instanceID": "ingredient-instance-id",
+            "relationship": "ingredient-relationship",
+            "c2pa_manifest": {
+              "url": "ingredient-manifest-url",
+              "alg": "ingredient-manifest-alg",
+              "hash": "b64'aGFzaA=='"
+            },
+            "thumbnail": {
+              "url": "ingredient-thumbnail-url",
+              "alg": "ingredient-thumbnail-alg",
+              "hash": "b64'aGFzaA=='"
+            },
+            "validationStatus": [
+              {
+                "code": "status.1",
+                "url": "url.1",
+                "explanation": "explanation.1"
+              },
+              {
+                "code": "status.2",
+                "url": "url.2",
+                "explanation": "explanation.2"
+              }
+            ],
+            "metadata": {
+              "reviewRatings": [
+                {
+                  "value": 1,
+                  "explanation": "can't dance to it"
+                }
+              ]
+            }
+          })json");
+  EXPECT_THAT(parser_.ParseCbor("c2pa.ingredient", ingredient_0_assertion_cbor,
+                                t.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t.tracker().GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kGoogleUnsupportedSpecVersion,
+                  "c2pa.ingredient")));
+}
+
+TEST_F(AssertionParserTest, ParseMultiAssetHashAssertion) {
+  credentio::TestValidationTracker t;
+  std::string multi_asset_hash_assertion_cbor = FromJson(R"json(
+    {
+      "parts": [
+        {
+          "location": {
+            "byteOffset": 0,
+            "length": 100
+          },
+          "hashAssertion": {
+            "url": "hash-url-1",
+            "alg": "sha256",
+            "hash": "b64'aGFzaA=='"
+          }
+        },
+        {
+          "location": {
+            "bmffBox": "test-bmff-box"
+          },
+          "hashAssertion": {
+            "url": "hash-url-2",
+            "alg": "sha256",
+            "hash": "b64'aGFzaA=='"
+          },
+          "optional": true
+        }
+      ],
+      "pad": "b64'Zm9v'"
+    })json");
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.hash.multi-asset",
+                        multi_asset_hash_assertion_cbor, t.tracker()),
+      Optional(EqualsProto(ParseTextProtoOrDie<credentio::Assertion>(R"pb(
+        label: "c2pa.hash.multi-asset"
+        multi_asset_hash {
+          parts {
+            location { byte_offset: 0 length: 100 }
+            hash_assertion {
+              url: "hash-url-1"
+              algorithm: "sha256"
+              hash: "hash"
+            }
+          }
+          parts {
+            location { bmff_box: "test-bmff-box" }
+            hash_assertion {
+              url: "hash-url-2"
+              algorithm: "sha256"
+              hash: "hash"
+            }
+            is_optional: true
+          }
+          pad: "foo"
+        }
+      )pb"))));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST(StripMultipleInstanceSuffixTest, Works) {
+  EXPECT_EQ(StripMultipleInstanceSuffix("c2pa.hash.data"), "c2pa.hash.data");
+  EXPECT_EQ(StripMultipleInstanceSuffix("c2pa.hash.data__1"), "c2pa.hash.data");
+  EXPECT_EQ(StripMultipleInstanceSuffix("c2pa.hash.data__13"),
+            "c2pa.hash.data");
+  EXPECT_EQ(StripMultipleInstanceSuffix(""), "");
+}
+
+TEST_F(AssertionParserTest, ParseActionsAssertionEmptyRelatedAssertions) {
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.opened",
+          "parameters": {
+            "relatedAssertions": []
+          }
+        }
+      ]
+    })json");
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.actions.v2", actions_assertion_cbor, t.tracker()),
+      Eq(std::nullopt));
+  EXPECT_THAT(t.GetFailureStatuses(),
+              testing::Contains(FailureStatusIs(
+                  credentio::FailureStatusCode::kAssertionCborInvalid,
+                  "c2pa.actions.v2")));
+}
+
+TEST_F(AssertionParserTest, ParseActionsAssertionMissingRelatedAssertions) {
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.opened",
+          "parameters": {
+            "instanceID": "ed610ae51f604002be3dbf0c589a2f1f"
+          }
+        }
+      ]
+    })json");
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.actions.v2", actions_assertion_cbor, t.tracker()),
+      testing::Ne(std::nullopt));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+TEST_F(AssertionParserTest, ParseActionsAssertionNonEmptyRelatedAssertions) {
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.opened",
+          "parameters": {
+            "relatedAssertions": [
+              {
+                "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.other",
+                "alg": "sha256",
+                "hash" : "b64'aGFzaA=='"
+              }
+            ]
+          }
+        }
+      ]
+    })json");
+  credentio::TestValidationTracker t;
+  EXPECT_THAT(
+      parser_.ParseCbor("c2pa.actions.v2", actions_assertion_cbor, t.tracker()),
+      testing::Ne(std::nullopt));
+  EXPECT_FALSE(t.tracker().HasFailures());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/assertion/assertion_referencer.cc b/assertion/assertion_referencer.cc
new file mode 100644
index 0000000..e70c5ca
--- /dev/null
+++ b/assertion/assertion_referencer.cc
@@ -0,0 +1,54 @@
+// 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 "assertion/assertion_referencer.h"
+
+#include <memory>
+#include <optional>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/hash.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/utils.h"
+#include "proto/assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+
+namespace credentio {
+
+absl::StatusOr<HashedUri> AssertionReferencer::Generate(
+    const jumbf::BuiltSuperBox& assertion_box) const {
+  if (!assertion_box.description_label().has_value()) {
+    return absl::InvalidArgumentError(
+        "the assertion box must have a description label");
+  }
+  HashedUri uri;
+  uri.set_url(absl::StrCat("self#jumbf=c2pa.assertions/",
+                           *assertion_box.description_label()));
+  absl::Cord serialized_assertion = assertion_box.Peek();
+  absl::string_view serialized_assertion_view = serialized_assertion.Flatten();
+  ABSL_ASSIGN_OR_RETURN(auto hasher, hasher_factory_.Create());
+  ABSL_ASSIGN_OR_RETURN(auto content,
+                        jumbf::StripBoxHeaders(serialized_assertion_view));
+  hasher->Update(content);
+  uri.set_hash(hasher->Digest());
+  return uri;
+}
+
+}  // namespace credentio
diff --git a/assertion/assertion_referencer.h b/assertion/assertion_referencer.h
new file mode 100644
index 0000000..8968034
--- /dev/null
+++ b/assertion/assertion_referencer.h
@@ -0,0 +1,50 @@
+// 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_ASSERTION_ASSERTION_REFERENCER_H_
+#define THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_REFERENCER_H_
+
+#include "absl/log/die_if_null.h"
+#include "absl/status/statusor.h"
+#include "crypto/hash.h"
+#include "jumbf/box_builder.h"
+#include "proto/assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+
+namespace credentio {
+// A utility class to create a hashed, JUMBF-type URI to an assertion.
+// For more details, see
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_uri_references
+// and
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hashed_uris.
+class AssertionReferencer {
+ public:
+  explicit AssertionReferencer(const HasherFactory* hasher)
+      : hasher_factory_(*ABSL_DIE_IF_NULL(hasher)) {}
+
+  // Generates a hashed URI for the given assertion. The URI is relative to the
+  // including manifest (e.g., "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3").
+  // The `algorithm` field field is omitted under the assumption that the
+  // HasherFactory's algorithm will be specified as the default hash algorithm
+  // in the Claim.
+  absl::StatusOr<HashedUri> Generate(
+      const jumbf::BuiltSuperBox& assertion) const;
+
+ private:
+  const HasherFactory& hasher_factory_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASSERTION_ASSERTION_REFERENCER_H_
diff --git a/assertion/assertion_referencer_test.cc b/assertion/assertion_referencer_test.cc
new file mode 100644
index 0000000..37bf5dc
--- /dev/null
+++ b/assertion/assertion_referencer_test.cc
@@ -0,0 +1,97 @@
+// 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 "assertion/assertion_referencer.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/mock_hash.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.h"
+#include "jumbf/test_utils.h"
+#include "proto/assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::HasSubstr;
+using ::testing::Return;
+
+TEST(AssertionReferencerTest, Generate) {
+  jumbf::SuperBoxBuilder builder(
+      jumbf::kCborBoxTypeUuid,
+      {.label = jumbf::CordFromConstexpr("c2pa.actions.v2")});
+  ASSERT_TRUE(builder
+                  .AddContent(jumbf::kCborBoxType,
+                              jumbf::CordFromConstexpr("fake CBOR"))
+                  .ok());
+  auto box = std::move(builder).Finalize();
+  ABSL_ASSERT_OK(box);
+  std::string expected_hash_input = absl::StrCat(
+      jumbf::EncodeDescriptionBox(jumbf::kCborBoxTypeUuid, "c2pa.actions.v2",
+                                  /*requestable=*/true),
+      jumbf::WrapBox("fake CBOR", jumbf::kCborBoxType));
+
+  testing::StrictMock<MockHasherFactory> hasher_factory;
+  auto hasher = std::make_unique<MockHasher>();
+  EXPECT_CALL(*hasher, Update(expected_hash_input)).Times(1);
+  EXPECT_CALL(*hasher, Digest()).WillOnce(Return("FAKE-HASH"));
+  EXPECT_CALL(hasher_factory, Create()).WillOnce(Return(std::move(hasher)));
+
+  AssertionReferencer assertion_referencer(&hasher_factory);
+  auto expected = ParseTextProtoOrDie<HashedUri>(R"pb(
+    url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+    hash: "FAKE-HASH"
+  )pb");
+  auto result = assertion_referencer.Generate(*box);
+  EXPECT_THAT(result, IsOkAndHolds(EqualsProto(expected)));
+}
+
+TEST(AssertionReferencerTest, FailsToGenerateWithoutLabel) {
+  jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {});
+  ASSERT_TRUE(builder
+                  .AddContent(jumbf::kCborBoxType,
+                              jumbf::CordFromConstexpr("fake CBOR"))
+                  .ok());
+  auto box = std::move(builder).Finalize();
+  ABSL_ASSERT_OK(box);
+
+  testing::StrictMock<MockHasherFactory> hasher_factory;
+
+  AssertionReferencer assertion_referencer(&hasher_factory);
+  EXPECT_THAT(
+      assertion_referencer.Generate(*box),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("the assertion box must have a description label")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/assertion/hashed_uri_validator.cc b/assertion/hashed_uri_validator.cc
new file mode 100644
index 0000000..4741a79
--- /dev/null
+++ b/assertion/hashed_uri_validator.cc
@@ -0,0 +1,81 @@
+// 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 "assertion/hashed_uri_validator.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+#include "jumbf/uri.h"
+#include "jumbf/utils.h"
+#include "proto/hashed_uri.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+std::optional<std::string> HashedUriValidator::Validate(
+    const HashedUri& hashed_uri, absl::string_view assertion_url,
+    ValidationTracker& validation_tracker) const {
+  auto record_failure = [&](FailureStatusCode code,
+                            absl::string_view explanation =
+                                "") -> std::optional<std::string> {
+    validation_tracker.RecordFailure(
+        code, {.url = assertion_url, .explanation = explanation});
+    return std::nullopt;
+  };
+
+  // Check the uri.
+  auto path = jumbf::UriResolver::GetAbsolutePathFromUri(hashed_uri.url(),
+                                                         manifest_path_);
+  if (!path.ok()) {
+    return record_failure(codes_.missing, path.status().message());
+  }
+  auto box = uri_resolver_.ResolvePath(*path);
+  if (!box.ok()) {
+    return record_failure(codes_.missing, box.status().message());
+  }
+
+  // Check the algorithm.
+  absl::string_view alg_name =
+      hashed_uri.algorithm().empty() ? alg_ : hashed_uri.algorithm();
+  auto algorithm = ParseHashAlgorithm(alg_name);
+  if (!algorithm.ok()) {
+    return record_failure(FailureStatusCode::kAlgorithmUnsupported);
+  }
+  auto checker = hash_checker_factory_.Create(*algorithm);
+  if (!checker.ok()) {
+    return record_failure(FailureStatusCode::kAlgorithmUnsupported);
+  }
+
+  // Check the hash.
+  auto box_bytes = jumbf::StripBoxHeaders((*box)->raw_bytes);
+  if (!box_bytes.ok()) {
+    return record_failure(codes_.mismatch);
+  }
+  (*checker)->Update(*box_bytes);
+  if (!(*checker)->Check(hashed_uri.hash())) {
+    return record_failure(codes_.mismatch);
+  }
+
+  return *path;
+}
+
+}  // namespace credentio
diff --git a/assertion/hashed_uri_validator.h b/assertion/hashed_uri_validator.h
new file mode 100644
index 0000000..54cdf0a
--- /dev/null
+++ b/assertion/hashed_uri_validator.h
@@ -0,0 +1,66 @@
+// 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_ASSERTION_HASHED_URI_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_ASSERTION_HASHED_URI_VALIDATOR_H_
+
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "jumbf/uri.h"
+#include "proto/hashed_uri.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+// A validator that validates `hashed_uri` according to
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validation_of_references
+class HashedUriValidator {
+ public:
+  struct Codes {
+    FailureStatusCode missing = FailureStatusCode::kHashedUriMissing;
+    FailureStatusCode mismatch = FailureStatusCode::kHashedUriMismatch;
+  };
+  HashedUriValidator(std::string manifest_path,
+                     const jumbf::UriResolver& uri_resolver,
+                     const HashCheckerFactory* hash_checker_factory = nullptr)
+      : manifest_path_(std::move(manifest_path)),
+        uri_resolver_(uri_resolver),
+        hash_checker_factory_(hash_checker_factory == nullptr
+                                  ? DefaultHashCheckerFactory()
+                                  : *hash_checker_factory) {}
+  void SetErrorCodes(Codes codes) { codes_ = codes; }
+  void SetDefaultAlgorithm(std::string alg) { alg_ = std::move(alg); }
+  // Returns the validated path if validation is successful (i.e., hash is
+  // matched); otherwise, std::nullopt.
+  std::optional<std::string> Validate(
+      const HashedUri& hashed_uri, absl::string_view assertion_url,
+      ValidationTracker& validation_tracker) const;
+
+ private:
+  std::string manifest_path_;
+  const jumbf::UriResolver& uri_resolver_;
+  const HashCheckerFactory& hash_checker_factory_;
+  Codes codes_;
+  std::string alg_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASSERTION_HASHED_URI_VALIDATOR_H_
diff --git a/assertion/hashed_uri_validator_test.cc b/assertion/hashed_uri_validator_test.cc
new file mode 100644
index 0000000..667872b
--- /dev/null
+++ b/assertion/hashed_uri_validator_test.cc
@@ -0,0 +1,248 @@
+// 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 "assertion/hashed_uri_validator.h"
+
+#include <memory>
+#include <optional>
+
+#include "absl/log/check.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/uri.h"
+#include "proto/hashed_uri.pb.h"
+#include "testing/jumbf_utils.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::Eq;
+using ::testing::IsEmpty;
+
+class FakeHashChecker : public HashChecker {
+ public:
+  explicit FakeHashChecker(absl::string_view hash_value)
+      : hash_value_(hash_value) {}
+  void Update(absl::string_view data) override {}
+  bool Check(absl::string_view hash) override { return hash == hash_value_; }
+
+ private:
+  absl::string_view hash_value_;
+};
+
+class FakeHashCheckerFactory : public HashCheckerFactory {
+ public:
+  explicit FakeHashCheckerFactory(absl::string_view hash_value)
+      : hash_value_(hash_value) {}
+
+  absl::StatusOr<std::unique_ptr<HashChecker>> Create(
+      HashAlgorithm algorithm) const override {
+    return std::make_unique<FakeHashChecker>(hash_value_);
+  }
+
+ private:
+  absl::string_view hash_value_;
+};
+
+class HashedUriValidatorTest : public ::testing::Test {
+ protected:
+  HashedUriValidatorTest()
+      : box_a_({
+            .description = {.label = "box_a"},
+            .raw_bytes = "box a contents",
+        }),
+        box_a_hash_("1CCMpryihdV55fyQNDUQgDLS5aXhkyGwEH6cVcNtmCE="),
+        manifest_store_(CreateManifestStore(
+            {CreateStandardManifest("urn:c2pa:manifest0", {}),
+             CreateStandardManifest("urn:c2pa:manifest1", {box_a_})})),
+        uri_resolver_(
+            jumbf::UriResolver::WithSingleRootChild(&manifest_store_)) {}
+
+  jumbf::SuperBox box_a_;
+  absl::string_view box_a_hash_;
+  jumbf::SuperBox manifest_store_;
+  jumbf::UriResolver uri_resolver_;
+  TestValidationTracker t_;
+};
+
+TEST_F(HashedUriValidatorTest, UriDoesNotResolve) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_);
+  EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb(
+                                   url: "no_such_box"
+                                 )pb"),
+                                 "", t_.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t_.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMissing));
+}
+
+TEST_F(HashedUriValidatorTest, UriDoesNotResolveCustomCode) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_);
+  validator.SetErrorCodes(
+      {.missing = FailureStatusCode::kIngredientManifestMissing,
+       .mismatch = FailureStatusCode::kIngredientManifestMismatch});
+  EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb(
+                                   url: "no_such_box"
+                                 )pb"),
+                                 "", t_.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t_.GetFailures(),
+              ContainsFailure(FailureStatusCode::kIngredientManifestMissing));
+}
+
+TEST_F(HashedUriValidatorTest, InvalidBoxHeader) {
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest("urn:c2pa:manifest0", {}),
+       CreateStandardManifest("urn:c2pa:manifest1",
+                              {{
+                                  .description = {.label = "box_a"},
+                                  .raw_bytes = "x",  // Invalid header
+                              }})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver);
+  EXPECT_THAT(
+      validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb(
+                           url: "self#jumbf=/c2pa/urn:c2pa:manifest1/box_a"
+                           algorithm: "sha256"
+                           hash: "abc"
+                         )pb"),
+                         "", t_.tracker()),
+      Eq(std::nullopt));
+  EXPECT_THAT(t_.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMismatch));
+}
+
+TEST_F(HashedUriValidatorTest, HashMismatch) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_);
+  EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb(
+                                   url: "self#jumbf=/c2pa/urn:c2pa:manifest1"
+                                   algorithm: "sha256"
+                                   hash: "abc"
+                                 )pb"),
+                                 "", t_.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t_.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMismatch));
+}
+
+TEST_F(HashedUriValidatorTest, HashMismatchCustomCode) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_);
+  validator.SetErrorCodes(
+      {.missing = FailureStatusCode::kIngredientManifestMissing,
+       .mismatch = FailureStatusCode::kIngredientManifestMismatch});
+  EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb(
+                                   url: "self#jumbf=/c2pa/urn:c2pa:manifest1"
+                                   algorithm: "sha256"
+                                   hash: "abc"
+                                 )pb"),
+                                 "", t_.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t_.GetFailures(),
+              ContainsFailure(FailureStatusCode::kIngredientManifestMismatch));
+}
+
+TEST_F(HashedUriValidatorTest, UnsupportedAlgorithm) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_);
+  EXPECT_THAT(validator.Validate(ParseTextProtoOrDie<HashedUri>(R"pb(
+                                   url: "self#jumbf=/c2pa/urn:c2pa:manifest1"
+                                   algorithm: "rutabaga256"
+                                   hash: "abc"
+                                 )pb"),
+                                 "", t_.tracker()),
+              Eq(std::nullopt));
+  EXPECT_THAT(t_.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAlgorithmUnsupported));
+}
+
+TEST_F(HashedUriValidatorTest, Success) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest0", uri_resolver_);
+  HashedUri hashed_uri;
+  hashed_uri.set_url("self#jumbf=/c2pa/urn:c2pa:manifest1/box_a");
+  hashed_uri.set_algorithm("sha256");
+  ASSERT_TRUE(
+      absl::Base64Unescape("1CCMpryihdV55fyQNDUQgDLS5aXhkyGwEH6cVcNtmCE=",
+                           hashed_uri.mutable_hash()));
+  EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()),
+              Eq("/c2pa/urn:c2pa:manifest1/box_a"));
+  EXPECT_THAT(t_.GetFailures(), IsEmpty());
+}
+
+TEST_F(HashedUriValidatorTest, RelativeUri) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest1", uri_resolver_);
+  HashedUri hashed_uri;
+  hashed_uri.set_url("self#jumbf=box_a");
+  hashed_uri.set_algorithm("sha256");
+  ASSERT_TRUE(
+      absl::Base64Unescape("1CCMpryihdV55fyQNDUQgDLS5aXhkyGwEH6cVcNtmCE=",
+                           hashed_uri.mutable_hash()));
+  EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()),
+              Eq("/c2pa/urn:c2pa:manifest1/box_a"));
+  EXPECT_THAT(t_.GetFailures(), IsEmpty());
+}
+
+TEST_F(HashedUriValidatorTest, DefaultAlgorithmIgnoredWithExplicitAlgorithm) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest1", uri_resolver_);
+  validator.SetDefaultAlgorithm("sha512");
+  HashedUri hashed_uri;
+  hashed_uri.set_url("self#jumbf=box_a");
+  hashed_uri.set_algorithm("sha256");
+  ASSERT_TRUE(
+      absl::Base64Unescape("1CCMpryihdV55fyQNDUQgDLS5aXhkyGwEH6cVcNtmCE=",
+                           hashed_uri.mutable_hash()));
+  EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()),
+              Eq("/c2pa/urn:c2pa:manifest1/box_a"));
+  EXPECT_THAT(t_.GetFailures(), IsEmpty());
+}
+
+TEST_F(HashedUriValidatorTest, DefaultAlgorithmUsed) {
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest1", uri_resolver_);
+  validator.SetDefaultAlgorithm("sha512");
+  HashedUri hashed_uri;
+  hashed_uri.set_url("self#jumbf=box_a");
+  ASSERT_TRUE(absl::Base64Unescape(
+      R"(69KGliqftQfRtFL+ouQG63R8RZef70mzgHC1GmUahLbyrFOzXliLyZkzDNwX2WfUGLYP1b4KCc5JRfl4OvtNSQ==)",
+      hashed_uri.mutable_hash()));
+  EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()),
+              Eq("/c2pa/urn:c2pa:manifest1/box_a"));
+  EXPECT_THAT(t_.GetFailures(), IsEmpty());
+}
+
+TEST_F(HashedUriValidatorTest, InjectedHashCheckerFactory) {
+  FakeHashCheckerFactory hash_checker_factory("fake_hash_value");
+  HashedUriValidator validator("/c2pa/urn:c2pa:manifest1", uri_resolver_,
+                               &hash_checker_factory);
+  auto hashed_uri = ParseTextProtoOrDie<HashedUri>(R"pb(
+    url: "self#jumbf=box_a"
+    algorithm: "sha256"
+    hash: "fake_hash_value"
+  )pb");
+  EXPECT_THAT(validator.Validate(hashed_uri, "", t_.tracker()),
+              Eq("/c2pa/urn:c2pa:manifest1/box_a"));
+  EXPECT_THAT(t_.GetFailures(), IsEmpty());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/assertion/parse_assertion.cc b/assertion/parse_assertion.cc
new file mode 100644
index 0000000..4d9c2b4
--- /dev/null
+++ b/assertion/parse_assertion.cc
@@ -0,0 +1,251 @@
+// 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 "assertion/parse_assertion.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/string_view.h"
+#include "cbor/parse.h"
+#include "google/protobuf/struct.pb.h"
+#include "nlohmann/json.hpp"
+#include "proto/actions_assertion.cbor.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/ai_disclosure_assertion.cbor.h"
+#include "proto/ai_disclosure_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/asset_reference_assertion.cbor.h"
+#include "proto/asset_reference_assertion.pb.h"
+#include "proto/asset_types_assertion.cbor.h"
+#include "proto/asset_types_assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.cbor.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.cbor.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/collection_data_hash_assertion.cbor.h"
+#include "proto/data_hash_assertion.cbor.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/environmental_sustainability_assertion.cbor.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/ingredient_assertion.cbor.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/metadata_assertion.pb.h"
+#include "proto/multi_asset_hash_assertion.cbor.h"
+#include "proto/repository_receipt_assertion.pb.h"
+#include "proto/soft_binding_assertion.cbor.h"
+#include "proto/soft_binding_assertion.pb.h"
+#include "proto/thumbnail_assertion.pb.h"
+
+namespace credentio {
+namespace {
+
+using Json = ::nlohmann::json;
+
+absl::Status ParseListValueHelper(const Json& json,
+                                  google::protobuf::ListValue* proto);
+absl::Status ParseValueHelper(const Json& json, google::protobuf::Value* proto);
+absl::Status ParseStructHelper(const Json& json,
+                               google::protobuf::Struct* proto);
+
+absl::Status ParseListValueHelper(const Json& json,
+                                  google::protobuf::ListValue* proto) {
+  for (const auto& wrapped_value : json.items()) {
+    // The parsed JSON has a redundant object wrapper for each list item.
+    // We only need the beginning item as it contains the index and the
+    // actual value.
+    const auto& [_, value] = wrapped_value.value().items().begin();
+    google::protobuf::Value* proto_value = proto->add_values();
+    ABSL_RETURN_IF_ERROR(ParseValueHelper(value, proto_value));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ParseValueHelper(const Json& json,
+                              google::protobuf::Value* proto) {
+  if (json.is_null()) {
+    proto->set_null_value(google::protobuf::NULL_VALUE);
+    return absl::OkStatus();
+  }
+  if (json.is_number()) {
+    proto->set_number_value(json.get<double>());
+    return absl::OkStatus();
+  }
+  if (json.is_string()) {
+    proto->set_string_value(json.get<std::string>());
+    return absl::OkStatus();
+  }
+  if (json.is_boolean()) {
+    proto->set_bool_value(json.get<bool>());
+    return absl::OkStatus();
+  }
+  if (json.is_object()) {
+    return ParseStructHelper(json, proto->mutable_struct_value());
+  }
+  if (json.is_array()) {
+    return ParseListValueHelper(json, proto->mutable_list_value());
+  }
+  return absl::InvalidArgumentError("Should not happen");
+}
+
+absl::Status ParseStructHelper(const Json& json,
+                               google::protobuf::Struct* proto) {
+  for (const auto& [key, value] : json.items()) {
+    google::protobuf::Value proto_value;
+    ABSL_RETURN_IF_ERROR(ParseValueHelper(value, &proto_value));
+    proto->mutable_fields()->insert({key, proto_value});
+  }
+  return absl::OkStatus();
+}
+
+template <typename T>
+absl::Status ParseAssertionHelper(absl::string_view assertion, T* proto) {
+  ABSL_ASSIGN_OR_RETURN(auto item, cbor::Parse(assertion));
+  ABSL_ASSIGN_OR_RETURN(auto map, item->AsMap());
+  return cbor::ToProto(map, proto);
+}
+
+template <typename T>
+void ClearPaddingFields(T* proto) {
+  proto->clear_pad();
+  proto->clear_pad2();
+}
+
+std::optional<std::string> GetJsonString(const Json& obj,
+                                         absl::string_view key) {
+  if (auto it = obj.find(key); it != obj.end() && it->is_string()) {
+    return it->get<std::string>();
+  }
+  return std::nullopt;
+}
+
+const Json* GetJsonObject(const Json& obj, absl::string_view key) {
+  if (auto it = obj.find(key); it != obj.end() && it->is_object()) {
+    return &(*it);
+  }
+  return nullptr;
+}
+
+}  // namespace
+
+#define C2PA_DEFINE_CBOR_ASSERTION_PARSER(ProtoType)                           \
+  absl::Status ParseAssertion(absl::string_view assertion, ProtoType* proto) { \
+    return ParseAssertionHelper(assertion, proto);                             \
+  }
+
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(ActionsAssertion)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(BmffBasedHashAssertion)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(IngredientAssertionV3)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(AssetReferenceAssertion)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(AssetTypesAssertion)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(BoxesHashAssertion)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(MultiAssetHashAssertion)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(CollectionDataHashAssertion)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(AiDisclosureAssertion)
+C2PA_DEFINE_CBOR_ASSERTION_PARSER(EnvironmentalSustainabilityAssertion)
+
+#undef C2PA_DEFINE_CBOR_ASSERTION_PARSER
+
+absl::Status ParseAssertion(absl::string_view assertion,
+                            ActionsAssertionV1* proto) {
+  ABSL_RETURN_IF_ERROR(ParseAssertionHelper(assertion, proto));
+  // Move v1 parameters field `ingredient` to v2 field `ingredients`.
+  for (auto& action : *proto->mutable_actions()) {
+    auto* params = action.mutable_parameters();
+    if (params->has_ingredient()) {
+      params->add_ingredients()->Swap(params->mutable_ingredient());
+      params->clear_ingredient();
+    }
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ParseAssertion(absl::string_view assertion,
+                            DataHashAssertion* proto) {
+  ABSL_RETURN_IF_ERROR(ParseAssertionHelper(assertion, proto));
+  // Clear irrelevant padding fields (for internal generator use only).
+  ClearPaddingFields(proto);
+  return absl::OkStatus();
+}
+
+absl::Status ParseAssertion(absl::string_view assertion,
+                            SoftBindingAssertion* proto) {
+  ABSL_RETURN_IF_ERROR(ParseAssertionHelper(assertion, proto));
+  // Clear irrelevant padding fields (for internal generator use only).
+  ClearPaddingFields(proto);
+  return absl::OkStatus();
+}
+
+absl::Status ParseAssertion(absl::string_view assertion,
+                            RepositoryReceiptAssertion* proto) {
+  auto json = Json::parse(assertion, nullptr, false);
+  if (json.is_discarded()) {
+    return absl::InvalidArgumentError("JSON parsing failed");
+  }
+  if (!json.is_object()) {
+    return absl::InvalidArgumentError("Assertion is not a JSON object");
+  }
+  if (const Json* repo_json = GetJsonObject(json, "repository")) {
+    auto* repository = proto->mutable_repository();
+    if (auto uri = GetJsonString(*repo_json, "uri")) {
+      repository->set_uri(*std::move(uri));
+    }
+    if (auto manifest_id = GetJsonString(*repo_json, "manifestId")) {
+      repository->set_manifest_id(*std::move(manifest_id));
+    }
+  }
+  if (const Json* anchor_json = GetJsonObject(json, "anchor")) {
+    auto* anchor = proto->mutable_anchor();
+    if (auto uri = GetJsonString(*anchor_json, "uri")) {
+      anchor->set_uri(*std::move(uri));
+    }
+    if (const Json* params = GetJsonObject(*anchor_json, "parameters")) {
+      ABSL_RETURN_IF_ERROR(
+          ParseStructHelper(*params, anchor->mutable_parameters()));
+    }
+    if (const Json* proof = GetJsonObject(*anchor_json, "proof")) {
+      ABSL_RETURN_IF_ERROR(ParseStructHelper(*proof, anchor->mutable_proof()));
+    }
+  }
+  return absl::OkStatus();
+}
+
+ThumbnailAssertion ParseThumbnailAssertion(
+    absl::string_view media_type, absl::string_view data,
+    std::optional<absl::string_view> file_name) {
+  ThumbnailAssertion proto;
+  proto.set_media_type(media_type);
+  proto.set_data(data);
+  if (file_name.has_value()) {
+    proto.set_file_name(*file_name);
+  }
+  return proto;
+}
+
+absl::Status ParseStruct(absl::string_view content,
+                         google::protobuf::Struct* proto) {
+  Json result = Json::parse(content.begin(), content.end(), /*cb=*/nullptr,
+                            /*allow_exceptions=*/false);
+  if (result.is_discarded()) {
+    return absl::InvalidArgumentError("Invalid JSON: could not parse");
+  }
+  return ParseStructHelper(result, proto);
+}
+
+}  // namespace credentio
diff --git a/assertion/parse_assertion.h b/assertion/parse_assertion.h
new file mode 100644
index 0000000..acf35ca
--- /dev/null
+++ b/assertion/parse_assertion.h
@@ -0,0 +1,119 @@
+// 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_ASSERTION_PARSE_ASSERTION_H_
+#define THIRD_PARTY_CREDENTIO_ASSERTION_PARSE_ASSERTION_H_
+
+#include <optional>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "google/protobuf/struct.pb.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/ai_disclosure_assertion.pb.h"
+#include "proto/asset_reference_assertion.pb.h"
+#include "proto/asset_types_assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/environmental_sustainability_assertion.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/metadata_assertion.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+#include "proto/repository_receipt_assertion.pb.h"
+#include "proto/soft_binding_assertion.pb.h"
+#include "proto/thumbnail_assertion.pb.h"
+
+namespace credentio {
+
+// Parses a CBOR-encoded `actions-map-v2`.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_8.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            ActionsAssertion* proto);
+
+// Parses a CBOR-encoded `actions-map-v1`.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            ActionsAssertionV1* proto);
+
+// Parses a CBOR-encoded `bmff-based-hash-map`.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_bmff_based_hash.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            BmffBasedHashAssertion* proto);
+
+// Parses a CBOR-encoded `data-hash-map`.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_data_hash.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            DataHashAssertion* proto);
+
+// Parses a CBOR-encoded `ingredient-map-v3`.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            IngredientAssertionV3* proto);
+
+// Parses a CBOR-encoded `asset-ref-map`.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_reference.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            AssetReferenceAssertion* proto);
+
+// Parses a CBOR-encoded `asset-type-map`.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_type.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            AssetTypesAssertion* proto);
+
+// Parses a CBOR-encoded `box-hash-map`.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_box_hash.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            BoxesHashAssertion* proto);
+
+// Parses a CBOR-encoded `multi-asset-hash-map`.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_multi_asset_hash.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            MultiAssetHashAssertion* proto);
+
+// Parses a CBOR-encoded `collection-data-hash-map`.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_collection_data_hash.
+absl::Status ParseAssertion(absl::string_view assertion,
+                            CollectionDataHashAssertion* proto);
+
+absl::Status ParseAssertion(absl::string_view assertion,
+                            AiDisclosureAssertion* proto);
+
+absl::Status ParseAssertion(absl::string_view assertion,
+                            EnvironmentalSustainabilityAssertion* proto);
+
+absl::Status ParseAssertion(absl::string_view assertion,
+                            RepositoryReceiptAssertion* proto);
+
+absl::Status ParseAssertion(absl::string_view assertion,
+                            SoftBindingAssertion* proto);
+
+ThumbnailAssertion ParseThumbnailAssertion(
+    absl::string_view media_type, absl::string_view data,
+    std::optional<absl::string_view> file_name);
+
+absl::Status ParseStruct(absl::string_view content,
+                         google::protobuf::Struct* proto);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASSERTION_PARSE_ASSERTION_H_
diff --git a/assertion/parse_assertion_test.cc b/assertion/parse_assertion_test.cc
new file mode 100644
index 0000000..2d327ad
--- /dev/null
+++ b/assertion/parse_assertion_test.cc
@@ -0,0 +1,985 @@
+// 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 "assertion/parse_assertion.h"
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/struct.pb.h"
+#include "gtest/gtest.h"
+#include "nlohmann/json.hpp"
+#include "proto/actions_assertion.pb.h"
+#include "proto/ai_disclosure_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/asset_reference_assertion.pb.h"
+#include "proto/asset_types_assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/environmental_sustainability_assertion.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/repository_receipt_assertion.pb.h"
+#include "testing/cbor_utils.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using Json = ::nlohmann::json;
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::cbor::FromJson;
+using ::credentio::ParseStruct;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::HasSubstr;
+
+TEST(ParseAssertionTest, ActionsAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "allActionsIncluded": true,
+      "actions": [
+        {
+          "action": "c2pa.created",
+          "softwareAgent": {
+              "name": "Joe's Photo Editor",
+              "version": "2.0",
+              "operating_system": "Windows 10"
+          },
+          "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+          "parameters": {
+            "instanceID": "ed610ae51f604002be3dbf0c589a2f1f",
+            "ingredients" : [
+              {
+                "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3__1",
+                "alg": "sha256",
+                "hash" : "b64'aGFzaA=='"
+              },
+              {
+                "url": "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient.v3__2",
+                "alg": "sha256",
+                "hash" : "b64'aGFzaA=='"
+              }
+            ],
+            "relatedAssertions": [
+              {
+                "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.other",
+                "alg": "sha256",
+                "hash" : "b64'aGFzaA=='"
+              }
+            ]
+          }
+        },
+        {
+          "action": "c2pa.filtered",
+          "softwareAgent" : {
+              "name": "Joe's Photo Editor",
+              "version": "2.0",
+              "operating_system": "Windows 10"
+          }
+        },
+        {
+          "action": "c2pa.cropped"
+        }
+      ],
+      "templates": [{
+        "action": "com.joesphoto.filter",
+        "description": "Magic Filter",
+        "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic",
+        "softwareAgent" : {
+            "name": "Joe's Photo Editor",
+            "version": "2.0",
+            "schema.org.SoftwareApplication.operatingSystem": "Windows 10"
+        },
+        "icon": {
+          "url": "http://cdn.examplephotoagency.com/logo.svg",
+          "hash": "b64'aGFzaA=='"
+        }
+      }]
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::ActionsAssertion>(R"pb(
+    all_actions_included: true
+    actions {
+      action: "c2pa.created"
+      digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+      software_agent {
+        name: "Joe\'s Photo Editor"
+        version: "2.0"
+        operating_system: "Windows 10"
+      }
+      parameters {
+        instance_id: "ed610ae51f604002be3dbf0c589a2f1f"
+        ingredients {
+          url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3__1"
+          algorithm: "sha256"
+          hash: "hash"
+        }
+        ingredients {
+          url: "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient.v3__2"
+          algorithm: "sha256"
+          hash: "hash"
+        }
+        related_assertions {
+          url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.other"
+          algorithm: "sha256"
+          hash: "hash"
+        }
+      }
+    }
+    actions {
+      action: "c2pa.filtered"
+      software_agent {
+        name: "Joe\'s Photo Editor"
+        version: "2.0"
+        operating_system: "Windows 10"
+      }
+    }
+    actions { action: "c2pa.cropped" }
+    templates {
+      action: "com.joesphoto.filter"
+      description: "Magic Filter"
+      digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic"
+      software_agent { name: "Joe\'s Photo Editor" version: "2.0" }
+      icon { url: "http://cdn.examplephotoagency.com/logo.svg" hash: "hash" }
+    }
+  )pb");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, EmptyRelatedAssertionsFails) {
+  auto cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created",
+          "softwareAgent": {
+              "name": "Joe's Photo Editor",
+              "version": "2.0",
+              "operating_system": "Windows 10"
+          },
+          "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+          "parameters": {
+            "instanceID": "ed610ae51f604002be3dbf0c589a2f1f",
+            "ingredients" : [
+              {
+                "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3__1",
+                "alg": "sha256",
+                "hash" : "b64'aGFzaA=='"
+              },
+              {
+                "url": "self#jumbf=c2pa/joe-ed:urn:uuid:EFGH/c2pa.assertions/c2pa.ingredient.v3__2",
+                "alg": "sha256",
+                "hash" : "b64'aGFzaA=='"
+              }
+            ],
+            "relatedAssertions": []
+          }
+        },
+        {
+          "action": "c2pa.filtered",
+          "softwareAgent" : {
+              "name": "Joe's Photo Editor",
+              "version": "2.0",
+              "operating_system": "Windows 10"
+          }
+        },
+        {
+          "action": "c2pa.cropped"
+        }
+      ],
+      "templates": [{
+        "action": "com.joesphoto.filter",
+        "description": "Magic Filter",
+        "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic",
+        "softwareAgent" : {
+            "name": "Joe's Photo Editor",
+            "version": "2.0",
+            "schema.org.SoftwareApplication.operatingSystem": "Windows 10"
+        },
+        "icon": {
+          "url": "http://cdn.examplephotoagency.com/logo.svg",
+          "hash": "b64'aGFzaA=='"
+        }
+      }]
+    })json");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Field relatedAssertions cannot be empty")));
+}
+
+TEST(ParseAssertionTest, ActionV1IngredientField) {
+  auto cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.opened",
+          "parameters": {
+            "ingredient" : {
+              "url": "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3",
+              "alg": "sha256",
+              "hash" : "b64'aGFzaA=='"
+            }
+          }
+        }
+      ]
+    })json");
+  // The `ingredient` field in v1 `parameters-map` is exposed in `ingredients`.
+  auto expected = ParseTextProtoOrDie<credentio::ActionsAssertionV1>(R"pb(
+    actions {
+      action: "c2pa.opened"
+      parameters {
+        ingredients {
+          url: "self#jumbf=c2pa/joe-ed:urn:uuid:ABCD/c2pa.assertions/c2pa.ingredient.v3"
+          algorithm: "sha256"
+          hash: "hash"
+        }
+      }
+    }
+  )pb");
+  credentio::ActionsAssertionV1 proto;
+  EXPECT_THAT(ParseAssertion(cbor, &proto), IsOk());
+  EXPECT_THAT(proto, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, ActionsAssertionInvalidNotAMap) {
+  auto cbor = FromJson(R"json([
+        {
+          "action": "c2pa.cropped"
+        }
+      ])json");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR parsed result is not a map")));
+}
+
+TEST(ParseAssertionTest, ActionsAssertionInvalidNoAction) {
+  auto cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "softwareAgent" : {
+              "name": "Joe's Photo Editor",
+              "version": "2.0",
+              "operating_system": "Windows 10"
+          }
+        }
+      ]
+    })json");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: action")));
+}
+
+TEST(ParseAssertionTest, ActionsAssertionInvalidActionNotAString) {
+  auto cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": 123
+        }
+      ]
+    })json");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: action")));
+}
+
+TEST(ParseAssertionTest, ActionsAssertionSkipsInvalidDigitalSourceType) {
+  auto cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created",
+          "digitalSourceType": 123
+        }
+      ]
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::ActionsAssertion>(R"pb(
+    actions { action: "c2pa.created" }
+  )pb");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, ActionsAssertionInvalidActionArrayContainsNonMap) {
+  auto cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created",
+          "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+        },
+        123
+      ]
+    })json");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR item is not a map")));
+}
+
+TEST(ParseAssertionTest, ActionsAssertionInvalidMalformedCbor) {
+  auto cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created"
+        }
+      ]
+    })json");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor.substr(0, 10), &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR parsing failed")));
+}
+
+TEST(ParseAssertionTest, ActionsAssertionSkipsInvalidNotArrayActions) {
+  // Invalid `actions`, as it should be an array, not a map.
+  auto cbor = FromJson(R"json(
+    {
+      "actions":
+        {
+          "action": "c2pa.created"
+        }
+    })json");
+  credentio::ActionsAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: actions")));
+}
+
+TEST(ParseAssertionTest, IngredientAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "dc:title": "ingredient-title",
+      "dc:format": "ingredient-format",
+      "relationship": "ingredient-relationship",
+      "instanceID": "ingredient-instance-id",
+      "data": {
+        "url": "ingredient-data-url",
+        "alg": "ingredient-data-alg",
+        "hash": "b64'aGFzaA=='",
+        "size": 1234567890
+      },
+      "activeManifest": {
+        "url": "ingredient-manifest-url",
+        "alg": "ingredient-manifest-alg",
+        "hash": "b64'aGFzaA=='"
+      },
+      "thumbnail": {
+        "url": "ingredient-thumbnail-url",
+        "alg": "ingredient-thumbnail-alg",
+        "hash": "b64'aGFzaA=='"
+      },
+      "validationResults": {
+        "activeManifest": {
+          "success": [
+            {
+              "code": "status.1",
+              "url": "url.1",
+              "explanation": "explanation.1"
+            },
+            {
+              "code": "status.2",
+              "url": "url.2",
+              "explanation": "explanation.2"
+            }
+          ],
+          "failure": [],
+          "informational": []
+        }
+      },
+      "description": "ingredient-description",
+      "informationalURI": "ingredient-informational-uri"
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::IngredientAssertionV3>(R"pb(
+    title: "ingredient-title"
+    media_type: "ingredient-format"
+    relationship: "ingredient-relationship"
+    instance_id: "ingredient-instance-id"
+    data {
+      url: "ingredient-data-url"
+      algorithm: "ingredient-data-alg"
+      hash: "hash"
+      size: 1234567890
+    }
+    active_manifest {
+      url: "ingredient-manifest-url"
+      algorithm: "ingredient-manifest-alg"
+      hash: "hash"
+    }
+    thumbnail {
+      url: "ingredient-thumbnail-url"
+      algorithm: "ingredient-thumbnail-alg"
+      hash: "hash"
+    }
+    validation_results {
+      active_manifest {
+        successes: {
+          code: "status.1"
+          url: "url.1"
+          explanation: "explanation.1"
+        }
+        successes: {
+          code: "status.2"
+          url: "url.2"
+          explanation: "explanation.2"
+        }
+      }
+    }
+    description: "ingredient-description"
+    informational_uri: "ingredient-informational-uri"
+  )pb");
+  credentio::IngredientAssertionV3 result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, AssetReferenceAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "references": [
+        {
+          "description": "A copy of the asset on the web",
+          "reference": {
+            "uri": "https://some.storage.us/foo"
+          }
+        },
+        {
+          "description": "A copy of the asset on IPFS",
+          "reference": {
+            "uri": "ipfs://cid"
+          }
+        }
+      ]
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::AssetReferenceAssertion>(R"pb(
+    references {
+      description: "A copy of the asset on the web"
+      reference { uri: "https://some.storage.us/foo" }
+    }
+    references {
+      description: "A copy of the asset on IPFS"
+      reference { uri: "ipfs://cid" }
+    }
+  )pb");
+  credentio::AssetReferenceAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, AssetReferenceAssertionInvalidMissingRequiredField) {
+  auto cbor = FromJson(R"json(
+    {
+      "references": [
+        {
+          "reference": {
+          }
+        }
+      ]
+    })json");
+  credentio::AssetReferenceAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: uri")));
+}
+
+TEST(ParseAssertionTest, AssetTypesAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "dc:format": "image/jpeg",
+      "types":
+      [
+        {
+          "type": "c2pa.types.model.tensorflow",
+          "version": "2.11.0"
+        },
+        {
+          "type": "c2pa.types.savedmodel",
+          "version": "2.11.0"
+        }
+      ]
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::AssetTypesAssertion>(R"pb(
+    media_type: "image/jpeg"
+    types { type: "c2pa.types.model.tensorflow" version: "2.11.0" }
+    types { type: "c2pa.types.savedmodel" version: "2.11.0" }
+  )pb");
+  credentio::AssetTypesAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, AssetTypesAssertionInvalidMissingRequiredField) {
+  auto cbor = FromJson(R"json(
+    {
+      "types":
+      [
+        {
+          "version": "2.11.0"
+        }
+      ]
+    })json");
+  credentio::AssetTypesAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: type")));
+}
+
+TEST(ParseAssertionTest, DataHashAssertionOkay) {
+  // The `pad` and `pad2` fields are omitted by the parser.
+  auto cbor = FromJson(R"json(
+    {
+      "alg": "sha256",
+      "pad": "b64'Zm9v'",
+      "pad2": "b64'Zm9v'",
+      "hash": "b64'Zm9v'",
+      "name": "JUMBF manifest",
+      "exclusions": [
+        {
+          "start": 9960,
+          "length": 4213
+        },
+        {
+          "start": 14173,
+          "length": 1000
+        }
+      ]
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::DataHashAssertion>(R"pb(
+    alg: "sha256"
+    hash: "foo"
+    name: "JUMBF manifest"
+    exclusions { start: 9960 length: 4213 }
+    exclusions { start: 14173 length: 1000 }
+  )pb");
+  credentio::DataHashAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, DataHashAssertionInvalidMissingRequiredField) {
+  auto cbor = FromJson(R"json(
+    {
+      "alg": "sha256"
+    })json");
+  credentio::DataHashAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: hash")));
+}
+
+TEST(ParseAssertionTest, BmffBasedHashAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "hash": "b64'Zm9v'",
+      "name": "Example `c2pa.hash.bmff.v2` assertion",
+      "exclusions": [
+          {
+            "data": [{ "value": "b64'Zm9v'", "offset": 8 }],
+            "xpath": "/uuid"
+          },
+          { "xpath": "/ftyp" },
+          { "xpath": "/mfra" },
+          { "xpath": "/moov[1]/pssh" },
+          {
+            "data": [{ "value": "b64'Zm9v'", "offset": 20 }],
+            "xpath": "/emsg"
+          }
+        ]
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::BmffBasedHashAssertion>(R"pb(
+    hash: "foo"
+    name: "Example `c2pa.hash.bmff.v2` assertion"
+    exclusions {
+      data { value: "foo" offset: 8 }
+      xpath: "/uuid"
+    }
+    exclusions { xpath: "/ftyp" }
+    exclusions { xpath: "/mfra" }
+    exclusions { xpath: "/moov[1]/pssh" }
+    exclusions {
+      data { value: "foo" offset: 20 }
+      xpath: "/emsg"
+    }
+  )pb");
+  credentio::BmffBasedHashAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, BmffBasedHashAssertionInvalidMissingRequiredField) {
+  auto cbor = FromJson(R"json(
+    {
+      "name": "Example `c2pa.hash.bmff.v2` assertion",
+      "exclusions": [
+        {
+          "data": [
+            {
+              "value": "2P7D1hsOSDySl1goh37EgQ==",
+              "offset": 8
+            }
+          ]
+        }
+      ]
+    })json");
+  credentio::BmffBasedHashAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: xpath")));
+}
+
+TEST(ParseAssertionTest, BoxesHashAssertionOkay) {
+  auto cbor = FromJson(R"json({
+    "alg" : "sha256",
+    "boxes": [
+      {
+        "names" : ["SOI", "APP0", "APP2"],
+        "hash" : "b64'Zm9v'"
+      },
+      {
+        "names" : ["C2PA"],
+        "hash" : "b64'YmFy'"
+      }
+    ]
+  })json");
+  auto expected = ParseTextProtoOrDie<credentio::BoxesHashAssertion>(R"pb(
+    alg: "sha256"
+    boxes { names: "SOI" names: "APP0" names: "APP2" hash: "foo" }
+    boxes { names: "C2PA" hash: "bar" }
+  )pb");
+  credentio::BoxesHashAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, BoxesHashAssertionInvalidMissingRequiredField) {
+  auto cbor = FromJson(R"json({
+    "boxes": [
+      {
+        "names" : ["SOI", "APP0", "APP2"]
+      }
+    ]
+  })json");
+  credentio::BoxesHashAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: hash")));
+}
+
+TEST(ParseAssertionTest, CollectionDataHashAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "alg": "sha256",
+      "uris": [
+        {
+          "uri": "foo1.txt",
+          "hash": "b64'Zm9v'",
+          "size": 1234,
+          "dc:format": "text/plain",
+          "data_types": [
+             {
+               "type": "c2pa.types.model.tensorflow",
+               "version": "2.11.0"
+             }
+          ]
+        },
+        {
+          "uri": "foo2.jpg",
+          "hash": "b64'YmFy'",
+          "dc:format": "image/jpeg"
+        }
+      ],
+      "zip_central_directory_hash": "b64'emlwSGFzaA=='"
+    })json");
+  auto expected =
+      ParseTextProtoOrDie<credentio::CollectionDataHashAssertion>(R"pb(
+        alg: "sha256"
+        uris {
+          uri: "foo1.txt"
+          hash: "foo"
+          size: 1234
+          type: "text/plain"
+          data_types { type: "c2pa.types.model.tensorflow" version: "2.11.0" }
+        }
+        uris { uri: "foo2.jpg" hash: "bar" type: "image/jpeg" }
+        zip_central_directory_hash: "zipHash"
+      )pb");
+  credentio::CollectionDataHashAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest,
+     CollectionDataHashAssertionInvalidMissingRequiredField) {
+  auto cbor = FromJson(R"json(
+    {
+      "uris": [
+        {
+          "uri": "foo1.txt",
+          "hash": "b64'Zm9v'"
+        }
+      ]
+    })json");
+  credentio::CollectionDataHashAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: alg")));
+}
+
+TEST(ParseAssertionTest, SoftBindingAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "alg": "soft-algorithm",
+      "name": "soft-binding-name",
+      "pad": "b64'Zm9v'",
+      "pad2": "b64'Zm9v'",
+      "alg-params": "b64'cGFyYW1z'",
+      "blocks": [
+        {
+          "scope": {
+            "extent": "b64'ZXh0ZW50'",
+            "timespan": {
+              "start": 100,
+              "end": 200
+            }
+          },
+          "value": "b64'dmFsdWU='"
+        }
+      ],
+      "bindingMetadata": {
+        "description": "metadata-desc",
+        "contact": "metadata-contact",
+        "informationalUrl": "metadata-url"
+      }
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::SoftBindingAssertion>(R"pb(
+    algorithm: "soft-algorithm"
+    name: "soft-binding-name"
+    alg_params: "params"
+    blocks {
+      scope {
+        extent: "extent"
+        timespan { start: 100 end: 200 }
+      }
+      value: "value"
+    }
+    binding_metadata {
+      description: "metadata-desc"
+      contact: "metadata-contact"
+      informational_url: "metadata-url"
+    }
+  )pb");
+  credentio::SoftBindingAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, SoftBindingAssertionMissingRequiredField) {
+  auto cbor = FromJson(R"json(
+    {
+      "name": "soft-binding-name"
+    })json");
+  credentio::SoftBindingAssertion result;
+  EXPECT_THAT(ParseAssertion(cbor, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Missing required field: alg")));
+}
+
+TEST(ParseAssertionTest, ParseStructOkay) {
+  auto json = R"json({
+    "string": "bar",
+    "int": 123,
+    "float": 123.456,
+    "bool": true,
+    "null": null,
+    "array": [1, 2, 3],
+    "object": {
+      "nested": "string"
+    }
+  })json";
+  auto expected = ParseTextProtoOrDie<google::protobuf::Struct>(R"pb(
+    fields {
+      key: "string"
+      value { string_value: "bar" }
+    }
+    fields {
+      key: "int"
+      value { number_value: 123 }
+    }
+    fields {
+      key: "float"
+      value { number_value: 123.456 }
+    }
+    fields {
+      key: "bool"
+      value { bool_value: true }
+    }
+    fields {
+      key: "null"
+      value { null_value: NULL_VALUE }
+    }
+    fields {
+      key: "array"
+      value {
+        list_value {
+          values { number_value: 1 }
+          values { number_value: 2 }
+          values { number_value: 3 }
+        }
+      }
+    }
+    fields {
+      key: "object"
+      value {
+        struct_value {
+          fields {
+            key: "nested"
+            value { string_value: "string" }
+          }
+        }
+      }
+    }
+  )pb");
+  google::protobuf::Struct result;
+  EXPECT_THAT(ParseStruct(json, &result), IsOk());
+  EXPECT_THAT(result, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, ParseStructInvalidJson) {
+  auto json = R"json({
+    "failed
+  })json";
+  google::protobuf::Struct result;
+  EXPECT_THAT(ParseStruct(json, &result),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Invalid JSON: could not parse")));
+}
+
+TEST(ParseAssertionTest, AiDisclosureAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "modelType": "c2pa.types.model.onnx",
+      "modelName": "My Awesome AI Model",
+      "modelIdentifier": "https://example.com/models/awesome-ai-v1",
+      "contentProfile": {
+        "humanOversightLevel": "prompt_guided",
+        "scientificDomain": ["cs.AI", "cs.CV"]
+      }
+    })json");
+  auto expected = ParseTextProtoOrDie<credentio::AiDisclosureAssertion>(R"pb(
+    model_type: "c2pa.types.model.onnx"
+    model_name: "My Awesome AI Model"
+    model_identifier: "https://example.com/models/awesome-ai-v1"
+    content_profile {
+      human_oversight_level: "prompt_guided"
+      scientific_domain: "cs.AI"
+      scientific_domain: "cs.CV"
+    }
+  )pb");
+  credentio::AiDisclosureAssertion actual;
+  ASSERT_THAT(credentio::ParseAssertion(cbor, &actual), IsOk());
+  EXPECT_THAT(actual, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, EnvironmentalSustainabilityAssertionOkay) {
+  auto cbor = FromJson(R"json(
+    {
+      "energy_kwh": {
+        "value": 1.23,
+        "measurementMethod": "com.example.direct-measurement"
+      },
+      "carbon_kgco2e": {
+        "value": 0.456
+      },
+      "water_litres": {
+        "value": 7.89,
+        "measurementMethod": "com.example.estimation"
+      }
+    })json");
+  auto expected =
+      ParseTextProtoOrDie<credentio::EnvironmentalSustainabilityAssertion>(R"pb(
+        energy_kwh {
+          value: 1.23
+          measurement_method: "com.example.direct-measurement"
+        }
+        carbon_kgco2e { value: 0.456 }
+        water_litres {
+          value: 7.89
+          measurement_method: "com.example.estimation"
+        }
+      )pb");
+  credentio::EnvironmentalSustainabilityAssertion actual;
+  ASSERT_THAT(credentio::ParseAssertion(cbor, &actual), IsOk());
+  EXPECT_THAT(actual, EqualsProto(expected));
+}
+
+TEST(ParseAssertionTest, RepositoryReceiptAssertionOkay) {
+  auto json = R"json(
+    {
+      "repository": {
+        "uri": "https://repository.example.com/manifests/xyz123",
+        "manifestId": "urn:uuid:12345678-1234-5678-1234-567812345678"
+      },
+      "anchor": {
+        "uri": "https://anchor.example.com/receipts/9876",
+        "parameters": {
+          "version": "1.0"
+        },
+        "proof": {
+          "signature": "c2lnbmF0dXJl"
+        }
+      }
+    })json";
+  auto expected =
+      ParseTextProtoOrDie<credentio::RepositoryReceiptAssertion>(R"pb(
+        repository {
+          uri: "https://repository.example.com/manifests/xyz123"
+          manifest_id: "urn:uuid:12345678-1234-5678-1234-567812345678"
+        }
+        anchor {
+          uri: "https://anchor.example.com/receipts/9876"
+          parameters {
+            fields {
+              key: "version"
+              value { string_value: "1.0" }
+            }
+          }
+          proof {
+            fields {
+              key: "signature"
+              value { string_value: "c2lnbmF0dXJl" }
+            }
+          }
+        }
+      )pb");
+  credentio::RepositoryReceiptAssertion actual;
+  ASSERT_THAT(credentio::ParseAssertion(json, &actual), IsOk());
+  EXPECT_THAT(actual, EqualsProto(expected));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/assertion/references_validator.cc b/assertion/references_validator.cc
new file mode 100644
index 0000000..bb68c59
--- /dev/null
+++ b/assertion/references_validator.cc
@@ -0,0 +1,172 @@
+// 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 "assertion/references_validator.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_split.h"
+#include "absl/strings/string_view.h"
+#include "assertion/assertion_parser.h"
+#include "assertion/hashed_uri_validator.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+bool ValidateHashedUri(const HashedUri& hashed_uri,
+                       const HashedUriValidator& hashed_uri_validator,
+                       absl::string_view assertion_url,
+                       ValidationTracker& validation_tracker) {
+  return hashed_uri_validator
+      .Validate(hashed_uri, assertion_url, validation_tracker)
+      .has_value();
+}
+
+bool IsUnsupportedRelatedAssertionLabel(absl::string_view label) {
+  return label == kIngredientAssertionV1Label ||
+         label == kIngredientAssertionV2Label ||
+         label == kIngredientAssertionV3Label ||
+         label == kActionsAssertionV1Label || label == kActionsAssertionV2Label;
+}
+
+bool ValidateTypeForRelatedAssertion(absl::string_view related_path,
+                                     absl::string_view manifest_label,
+                                     absl::string_view assertion_url,
+                                     ValidationTracker& validation_tracker) {
+  const std::vector<absl::string_view> related_tokens =
+      absl::StrSplit(related_path, '/');
+  if (related_tokens.size() <= 3) {
+    return false;
+  }
+
+  if (related_tokens[2] != manifest_label) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionActionMalformed,
+        {.url = assertion_url,
+         .explanation = "Related Assertion is not in the same manifest as the "
+                        "action assertion."});
+    return false;
+  }
+
+  const absl::string_view label =
+      StripMultipleInstanceSuffix(related_tokens.back());
+  if (IsUnsupportedRelatedAssertionLabel(label)) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionActionMalformed,
+        {.url = assertion_url,
+         .explanation =
+             absl::StrCat("unsupported related assertion type: ", label)});
+    return false;
+  }
+  return true;
+}
+
+bool ValidateRelatedAssertions(const ActionParameters& parameters,
+                               const HashedUriValidator& hashed_uri_validator,
+                               absl::string_view manifest_label,
+                               absl::string_view assertion_url,
+                               ValidationTracker& validation_tracker) {
+  for (const auto& related_uri : parameters.related_assertions()) {
+    auto path = hashed_uri_validator.Validate(related_uri, assertion_url,
+                                              validation_tracker);
+    if (!path.has_value()) {
+      // Note: C2PA 2.4 Validation
+      // (https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_actions_validation)
+      // states that the HashedUri should be validated (which could add failure
+      // codes), and then states to additionally add the failure code
+      // assertion.action.malformed if the referenced assertion can not be
+      // resolved (i.e. is missing or hash mismatch) this results in two
+      // failures for the same issue.
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionActionMalformed,
+          {.url = assertion_url,
+           .explanation = absl::StrCat("could not resolve related assertion: ",
+                                       related_uri.url())});
+      return false;
+    }
+    if (!ValidateTypeForRelatedAssertion(*path, manifest_label, assertion_url,
+                                         validation_tracker)) {
+      return false;
+    }
+  }
+  return true;
+}
+
+template <typename ActionsT>
+bool ValidateActions(const ActionsT& actions,
+                     const HashedUriValidator& hashed_uri_validator,
+                     absl::string_view manifest_label,
+                     absl::string_view assertion_url,
+                     ValidationTracker& validation_tracker) {
+  for (const auto& action : actions.actions()) {
+    if (!action.has_parameters()) {
+      continue;
+    }
+    for (const auto& ingredient : action.parameters().ingredients()) {
+      if (!ValidateHashedUri(ingredient, hashed_uri_validator, assertion_url,
+                             validation_tracker)) {
+        return false;
+      }
+    }
+    if (!ValidateRelatedAssertions(action.parameters(), hashed_uri_validator,
+                                   manifest_label, assertion_url,
+                                   validation_tracker)) {
+      return false;
+    }
+  }
+  return true;
+}
+
+bool ValidateIngredientV3(const IngredientAssertionV3& ingredient,
+                          const HashedUriValidator& hashed_uri_validator,
+                          absl::string_view assertion_url,
+                          ValidationTracker& validation_tracker) {
+  // `active_manifest` and `claim_signature` are validated in `ManifestGraph`
+  // when appropriate -- the `active_manifest` hash won't validate when the
+  // ingredient has redactions.
+  return !ingredient.has_thumbnail() ||
+         ValidateHashedUri(ingredient.thumbnail(), hashed_uri_validator,
+                           assertion_url, validation_tracker);
+}
+}  // namespace
+
+bool ReferencesValidator::Validate(const Assertion& assertion) const {
+  const std::string assertion_url = absl::StrCat(
+      kAssertionStoreLabel, kManifestLabelDelimiter, assertion.label());
+  switch (assertion.assertion_case()) {
+    case Assertion::kActionsV1:
+      return ValidateActions(assertion.actions_v1(), hashed_uri_validator_,
+                             manifest_label_, assertion_url, tracker_);
+    case Assertion::kActions:
+      return ValidateActions(assertion.actions(), hashed_uri_validator_,
+                             manifest_label_, assertion_url, tracker_);
+    case Assertion::kIngredientV3:
+      return ValidateIngredientV3(assertion.ingredient_v3(),
+                                  hashed_uri_validator_, assertion_url,
+                                  tracker_);
+    default:
+      return true;
+  }
+}
+}  // namespace credentio
diff --git a/assertion/references_validator.h b/assertion/references_validator.h
new file mode 100644
index 0000000..416c652
--- /dev/null
+++ b/assertion/references_validator.h
@@ -0,0 +1,45 @@
+// 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_ASSERTION_REFERENCES_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_ASSERTION_REFERENCES_VALIDATOR_H_
+
+#include "absl/log/die_if_null.h"
+#include "absl/strings/string_view.h"
+#include "assertion/hashed_uri_validator.h"
+#include "proto/assertion.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+// Validation of references according to
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validation_of_references.
+class ReferencesValidator {
+ public:
+  ReferencesValidator(const HashedUriValidator* hashed_uri_validator,
+                      absl::string_view manifest_label,
+                      ValidationTracker* tracker)
+      : hashed_uri_validator_(*ABSL_DIE_IF_NULL(hashed_uri_validator)),
+        manifest_label_(manifest_label),
+        tracker_(*ABSL_DIE_IF_NULL(tracker)) {}
+  bool Validate(const Assertion& assertion) const;
+
+ private:
+  const HashedUriValidator& hashed_uri_validator_;
+  const absl::string_view manifest_label_;
+  ValidationTracker& tracker_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASSERTION_REFERENCES_VALIDATOR_H_
diff --git a/assertion/references_validator_test.cc b/assertion/references_validator_test.cc
new file mode 100644
index 0000000..2adc17a
--- /dev/null
+++ b/assertion/references_validator_test.cc
@@ -0,0 +1,622 @@
+// 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 "assertion/references_validator.h"
+
+#include <memory>
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "assertion/hashed_uri_validator.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/uri.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "testing/jumbf_utils.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::IsEmpty;
+using ::testing::UnorderedElementsAre;
+
+constexpr absl::string_view kManifestLabel1 =
+    "urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4";
+constexpr absl::string_view kManifestPath1 =
+    "/c2pa/urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4";
+constexpr absl::string_view kManifestLabel2 =
+    "urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA";
+
+std::string Digest(absl::string_view data) {
+  auto hasher = CreateHasher("sha256");
+  CHECK_OK(hasher);
+  (*hasher)->Update(data);
+  return (*hasher)->Digest();
+}
+
+TEST(ReferencesValidatorTest, ValidateIngredientV3ThumbnailOkay) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.ingredient"
+        ingredient_v3 {
+          thumbnail {
+            url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg"
+            algorithm: "sha256"
+            hash: "%s"
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox thumbnail_box = {
+      .description =
+          {
+              .label = "c2pa.thumbnail.ingredient_1.jpg",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel2, {thumbnail_box})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_TRUE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailureStatuses(), IsEmpty());
+}
+
+TEST(ReferencesValidatorTest, ValidateIngredientV3ThumbnailMissing) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.ingredient"
+        ingredient_v3 {
+          thumbnail {
+            url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg"
+            algorithm: "sha256"
+            hash: "%s"
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({CreateStandardManifest(kManifestLabel2, {})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_FALSE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(), UnorderedElementsAre("hashedUri.missing"));
+}
+
+TEST(ReferencesValidatorTest, ValidateIngredientV3ThumbnailMismatch) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("bar");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.ingredient"
+        ingredient_v3 {
+          thumbnail {
+            url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg"
+            algorithm: "sha256"
+            hash: "%s"
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox thumbnail_box = {
+      .description =
+          {
+              .label = "c2pa.thumbnail.ingredient_1.jpg",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel2, {thumbnail_box})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_FALSE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(),
+              UnorderedElementsAre("hashedUri.mismatch"));
+}
+
+TEST(ReferencesValidatorTest, ValidateIngredientV3ActiveManifestOkay) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          active_manifest {
+            url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA"
+            algorithm: "sha256"
+            hash: "%s"
+          }
+        }
+      )pb",
+      expected_hash));
+  auto manifest_box = CreateStandardManifest(kManifestLabel2, {});
+  manifest_box.raw_bytes = "00020000foo";
+  jumbf::SuperBox manifest_store = CreateManifestStore({manifest_box});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_TRUE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ReferencesValidatorTest,
+     ValidateIngredientV3ActiveManifestMismatchIgnored) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.ingredient"
+        ingredient_v3 {
+          active_manifest {
+            url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA"
+            algorithm: "sha256"
+            hash: "%s"
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({CreateStandardManifest(kManifestLabel2, {})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  EXPECT_TRUE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ReferencesValidatorTest,
+     ValidateIngredientV3ClaimSignatureMismatchIgnored) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("bar");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.ingredient"
+        ingredient_v3 {
+          claim_signature {
+            url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.signature"
+            algorithm: "sha256"
+            hash: "%s"
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox claim_signature_box = {
+      .description =
+          {
+              .label = "c2pa.signature",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel2, {claim_signature_box})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  EXPECT_TRUE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ReferencesValidatorTest, ValidateActionsV1IngredientOkay) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions_v1 {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox ingredient_box = {
+      .description =
+          {
+              .label = "c2pa.ingredient__1",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  auto assertion_store = CreateAssertionStoreBox({ingredient_box});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_TRUE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ReferencesValidatorTest, ValidateActionsV1IngredientMismatch) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("bar");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions_v1 {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox ingredient_box = {
+      .description =
+          {
+              .label = "c2pa.ingredient__1",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  auto assertion_store = CreateAssertionStoreBox({ingredient_box});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_FALSE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(),
+              UnorderedElementsAre("hashedUri.mismatch"));
+}
+
+TEST(ReferencesValidatorTest, ValidateActionsV2IngredientOkay) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox ingredient_box = {
+      .description =
+          {
+              .label = "c2pa.ingredient__1",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  auto assertion_store = CreateAssertionStoreBox({ingredient_box});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_TRUE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ReferencesValidatorTest, ValidateActionsV2IngredientMismatch) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("bar");
+  auto assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox ingredient_box = {
+      .description =
+          {
+              .label = "c2pa.ingredient__1",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  auto assertion_store = CreateAssertionStoreBox({ingredient_box});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_FALSE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetFailures(),
+              UnorderedElementsAre("hashedUri.mismatch"));
+}
+
+TEST(ReferencesValidatorTest, ValidateActionsV2RelatedAssertionsOkay) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              related_assertions {
+                url: "self#jumbf=c2pa.assertions/c2pa.other"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox other_assertion_box = {
+      .description =
+          {
+              .label = "c2pa.other",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  auto assertion_store = CreateAssertionStoreBox({other_assertion_box});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_TRUE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(tracker.GetFailures(), IsEmpty());
+}
+
+TEST(ReferencesValidatorTest, ValidateActionsV2RelatedAssertionsMissing) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              related_assertions {
+                url: "self#jumbf=c2pa.assertions/c2pa.other"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({CreateStandardManifest(kManifestLabel1, {})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_FALSE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      UnorderedElementsAre("hashedUri.missing", "assertion.action.malformed"));
+}
+
+TEST(ReferencesValidatorTest, ValidateActionsV2RelatedAssertionsMismatch) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("bar");
+  Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              related_assertions {
+                url: "self#jumbf=c2pa.assertions/c2pa.other"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox other_assertion_box = {
+      .description =
+          {
+              .label = "c2pa.other",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  auto assertion_store = CreateAssertionStoreBox({other_assertion_box});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_FALSE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(
+      tracker.GetFailures(),
+      UnorderedElementsAre("hashedUri.mismatch", "assertion.action.malformed"));
+}
+
+TEST(ReferencesValidatorTest, ValidateActionsV2RelatedAssertionsIngredient) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              related_assertions {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  jumbf::SuperBox ingredient_box = {
+      .description =
+          {
+              .label = "c2pa.ingredient__1",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ingredient_box});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_FALSE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(tracker.GetFailures(),
+              UnorderedElementsAre("assertion.action.malformed"));
+}
+
+TEST(ReferencesValidatorTest,
+     ValidateActionsV2RelatedAssertionsInSeparateManifest) {
+  TestValidationTracker tracker;
+  auto expected_hash = Digest("foo");
+  Assertion assertion = ParseTextProtoOrDie<Assertion>(absl::StrFormat(
+      R"pb(
+        label: "c2pa.action"
+        actions {
+          actions {
+            action: "c2pa.opened",
+            parameters {
+              related_assertions {
+                url: "self#jumbf=c2pa.assertions/c2pa.other"
+                algorithm: "sha256"
+                hash: "%s"
+              }
+            }
+          }
+        }
+      )pb",
+      expected_hash));
+  assertion.mutable_actions()
+      ->mutable_actions(0)
+      ->mutable_parameters()
+      ->mutable_related_assertions(0)
+      ->set_url(absl::StrCat("self#jumbf=/c2pa/", kManifestLabel2,
+                             "/c2pa.assertions/c2pa.other"));
+
+  jumbf::SuperBox ingredient_box = {
+      .description =
+          {
+              .label = "c2pa.other",
+          },
+      .raw_bytes = "00020000foo",
+  };
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({ingredient_box});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel2, {assertion_store}),
+       CreateStandardManifest(kManifestLabel1, {})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  HashedUriValidator hashed_uri_validator(std::string(kManifestPath1),
+                                          uri_resolver);
+  ReferencesValidator references_validator(&hashed_uri_validator,
+                                           kManifestLabel1, &tracker.tracker());
+  ASSERT_FALSE(references_validator.Validate(assertion));
+  EXPECT_THAT(tracker.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(tracker.GetFailures(),
+              UnorderedElementsAre("assertion.action.malformed"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/assertion/validator.cc b/assertion/validator.cc
new file mode 100644
index 0000000..cfd64d9
--- /dev/null
+++ b/assertion/validator.cc
@@ -0,0 +1,703 @@
+// 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 "assertion/validator.h"
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/no_destructor.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/log/log.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "assertion/actions_assertion_validator.h"
+#include "assertion/assertion_parser.h"
+#include "assertion/hashed_uri_validator.h"
+#include "assertion/references_validator.h"
+#include "constants/ingredient_relationships.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "google/protobuf/repeated_ptr_field.h"
+#include "jumbf/box.h"
+#include "jumbf/uri.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::jumbf::BinaryDataBox;
+using ::jumbf::CborBox;
+using ::jumbf::EmbeddedFileDescriptionBox;
+using ::jumbf::JsonBox;
+using ::jumbf::SuperBox;
+
+constexpr absl::string_view kUnknown = "c2pa.unknown";
+
+enum ManifestType {
+  kStandardManifest,
+  kUpdateManifest,
+};
+
+absl::string_view Basename(absl::string_view path) {
+  const size_t pos = path.find_last_of('/');
+  if (pos == absl::string_view::npos) {
+    return path;
+  }
+  return path.substr(pos + 1);
+}
+
+template <typename ActionsT>
+bool ValidateActions(const ActionsT& actions, absl::string_view assertion_url,
+                     ValidationTracker& validation_tracker) {
+  for (const auto& action : actions.actions()) {
+    if (action.action() == kUnknown) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kGoogleAssertionActionUnknownAction,
+          {.url = assertion_url});
+      return false;
+    }
+  }
+  return true;
+}
+
+bool HasSuccesses(const ValidationStatusSet& validation) {
+  return !validation.successes().empty();
+}
+
+bool HasFailures(const ValidationStatusSet& validation) {
+  return !validation.failures().empty();
+}
+
+bool IsHardBindingSuccessCode(absl::string_view code) {
+  static const absl::NoDestructor<absl::flat_hash_set<std::string>>
+      kHardBindingSuccessCodes({
+          absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch),
+          absl::StrCat(SuccessStatusCode::kAssertionBoxesHashMatch),
+          absl::StrCat(SuccessStatusCode::kAssertionCollectionHashMatch),
+          absl::StrCat(SuccessStatusCode::kAssertionDataHashMatch),
+      });
+  return kHardBindingSuccessCodes->contains(code);
+}
+
+bool HasHardBindingSuccess(const ValidationStatusSet& validation) {
+  return std::any_of(validation.successes().begin(),
+                     validation.successes().end(), [](const auto& status) {
+                       return IsHardBindingSuccessCode(status.code());
+                     });
+}
+
+bool ValidateIngredientAssertionValidationResults(
+    const IngredientAssertionV3& ingredient, absl::string_view assertion_url,
+    ManifestType manifest_type, ValidationTracker& validation_tracker) {
+  if (!ingredient.has_active_manifest()) {
+    // No validation results expected.
+    return true;
+  }
+  const ValidationStatusSet& ingredient_validation =
+      ingredient.validation_results().active_manifest();
+  if (!HasFailures(ingredient_validation)) {
+    switch (manifest_type) {
+      case kStandardManifest:
+        if (!HasHardBindingSuccess(ingredient_validation)) {
+          validation_tracker.RecordFailure(
+              FailureStatusCode::kAssertionIngredientMalformed,
+              {.url = assertion_url,
+               .explanation = "no hard binding check recorded"});
+          return false;
+        }
+        break;
+      case kUpdateManifest:
+        if (!HasSuccesses(ingredient_validation)) {
+          validation_tracker.RecordFailure(
+              FailureStatusCode::kAssertionIngredientMalformed,
+              {.url = assertion_url,
+               .explanation = "no validation successes or failures recorded"});
+          return false;
+        }
+        break;
+    }
+  }
+  return true;
+}
+
+bool IsValidIngredientRelationship(absl::string_view relationship) {
+  return relationship == kIngredientRelationshipParentOf ||
+         relationship == kIngredientRelationshipComponentOf ||
+         relationship == kIngredientRelationshipInputTo;
+}
+
+bool ValidateIngredientAssertion(const IngredientAssertionV3& ingredient,
+                                 absl::string_view assertion_url,
+                                 ManifestType manifest_type,
+                                 ValidationTracker& validation_tracker) {
+  if (ingredient.has_active_manifest() &&
+      !ingredient.digital_source_type().empty()) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionIngredientMalformed,
+        {.url = assertion_url,
+         .explanation = "Ingredient assertion must not contain both "
+                        "activeManifest and digitalSourceType"});
+    return false;
+  }
+  // Note that while
+  // https://spec.c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html#_performing_explicit_validation
+  // only calls for relationship validation on v3 ingredient assertions, this
+  // was subsequently corrected for C2PA 2.2 in
+  // https://github.com/c2pa-org/specs-core/pull/1513.
+  if (ingredient.relationship().empty()) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionIngredientMalformed,
+        {.url = assertion_url,
+         .explanation = "Missing ingredient relationship"});
+    return false;
+  }
+  if (!IsValidIngredientRelationship(ingredient.relationship())) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionIngredientMalformed,
+        {.url = assertion_url,
+         .explanation = "Unrecognized ingredient relationship"});
+    return false;
+  }
+  return ValidateIngredientAssertionValidationResults(
+      ingredient, assertion_url, manifest_type, validation_tracker);
+}
+
+bool ActionAllowedInUpdateManifest(absl::string_view action) {
+  return action == "c2pa.edited.metadata" || action == "c2pa.opened" ||
+         action == "c2pa.published" || action == "c2pa.redacted";
+}
+
+struct AssertionCounter {
+  uint32_t hard_bindings = 0;
+  uint32_t ingredients = 0;
+  uint32_t thumbnails = 0;
+  std::string action_disallowed_in_update;
+  absl::flat_hash_map<std::string, uint32_t> ingredient_relationships;
+  bool has_soft_binding = false;
+  bool has_watermarked_bound_action = false;
+
+  void RecordAction(absl::string_view action) {
+    if (action_disallowed_in_update.empty() &&
+        !ActionAllowedInUpdateManifest(action)) {
+      action_disallowed_in_update = action;
+    }
+    if (action == "c2pa.watermarked.bound" || action == "c2pa.watermarked") {
+      has_watermarked_bound_action = true;
+    }
+  }
+
+  void Add(const Assertion& assertion) {
+    if (assertion.has_soft_binding()) {
+      has_soft_binding = true;
+    }
+    if (assertion.has_data_hash() || assertion.has_bmff_based_hash() ||
+        assertion.has_boxes_hash() || assertion.has_collection_data_hash()) {
+      if (!absl::StrContains(assertion.label(), ".part")) {
+        // Only count the full asset hard binding.
+        ++hard_bindings;
+      }
+    }
+    for (const auto& action : assertion.actions().actions()) {
+      RecordAction(action.action());
+    }
+    for (const auto& action : assertion.actions_v1().actions()) {
+      RecordAction(action.action());
+    }
+    if (assertion.has_ingredient_v3()) {
+      ++ingredients;
+      ++ingredient_relationships[assertion.ingredient_v3().relationship()];
+    }
+    if (assertion.has_claim_thumbnail() ||
+        assertion.has_ingredient_thumbnail()) {
+      ++thumbnails;
+    }
+  }
+
+  // See:
+  // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_validate_the_assertions
+  void ValidateStandardManifestCounts(absl::string_view claim_box_url,
+                                      ValidationTracker& validation_tracker) {
+    if (hard_bindings == 0) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kClaimHardBindingsMissing,
+          {.url = claim_box_url,
+           .explanation = "Standard manifest must contain one hard binding."});
+    } else if (hard_bindings > 1) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionMultipleHardBindings,
+          {.url = kAssertionStoreLabel,
+           .explanation = "Standard manifest must not contain more than one "
+                          "hard binding."});
+    }
+    if (ingredient_relationships[kIngredientRelationshipParentOf] > 1) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kManifestMultipleParents,
+          {.url = claim_box_url,
+           .explanation = "Standard manifest must contain at most one "
+                          "ingredient with parentOf relationship."});
+    }
+  }
+
+  // See:
+  // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_validate_the_assertions
+  void ValidateUpdateManifestCounts(absl::string_view claim_box_url,
+                                    ValidationTracker& validation_tracker) {
+    if (hard_bindings != 0) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kManifestUpdateInvalid,
+          {.url = claim_box_url,
+           .explanation = "Update manifest must not contain hard bindings."});
+    }
+    if (!action_disallowed_in_update.empty()) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kManifestUpdateInvalid,
+          {.url = claim_box_url,
+           .explanation =
+               absl::StrCat("Update manifest contains disallowed action: ",
+                            action_disallowed_in_update)});
+    }
+    if (ingredients != 1) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kManifestUpdateWrongParents,
+          {.url = claim_box_url,
+           .explanation = absl::StrCat(
+               "Update manifest must contain exactly one ingredient; found ",
+               ingredients)});
+    } else if (ingredient_relationships[kIngredientRelationshipParentOf] != 1) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kManifestUpdateWrongParents,
+          {.url = claim_box_url,
+           .explanation =
+               "Update manifest ingredient relationship must be parentOf."});
+    }
+  }
+
+  void ValidateSoftBinding(absl::string_view claim_box_url,
+                           ValidationTracker& validation_tracker) {
+    if (has_watermarked_bound_action && !has_soft_binding) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionActionSoftBindingMissing,
+          {.url = claim_box_url,
+           .explanation = "c2pa.watermarked.bound action requires a "
+                          "c2pa.soft-binding assertion"});
+    }
+  }
+};
+
+void ValidateCborAssertion(
+    absl::string_view label, absl::string_view payload,
+    ManifestType manifest_type, const AssertionParser& assertion_parser,
+    const ReferencesValidator& references_validator,
+    google::protobuf::RepeatedPtrField<Assertion>* assertions_out,
+    ValidationTracker& validation_tracker,
+    AssertionCounter& assertion_counter) {
+  std::optional<Assertion> assertion_proto =
+      assertion_parser.ParseCbor(label, payload, validation_tracker);
+  if (!assertion_proto.has_value()) {
+    return;
+  }
+  assertion_counter.Add(*assertion_proto);
+  if (!references_validator.Validate(*assertion_proto)) {
+    return;
+  }
+  const std::string assertion_url = absl::StrCat(
+      kAssertionStoreLabel, kManifestLabelDelimiter, assertion_proto->label());
+  switch (assertion_proto->assertion_case()) {
+    case Assertion::kActionsV1:
+      if (!ValidateActions(assertion_proto->actions_v1(), assertion_url,
+                           validation_tracker)) {
+        return;
+      }
+      break;
+    case Assertion::kActions:
+      if (!ValidateActions(assertion_proto->actions(), assertion_url,
+                           validation_tracker)) {
+        return;
+      }
+      break;
+    case Assertion::kIngredientV3:
+      if (!ValidateIngredientAssertion(assertion_proto->ingredient_v3(),
+                                       assertion_url, manifest_type,
+                                       validation_tracker)) {
+        return;
+      }
+      break;
+    default:
+      break;
+  }
+  assertions_out->Add(*std::move(assertion_proto));
+}
+
+void ValidateAssertionContents(
+    const SuperBox* assertion_box, ManifestType manifest_type,
+    const AssertionParser& assertion_parser,
+    const ReferencesValidator& references_validator,
+    google::protobuf::RepeatedPtrField<Assertion>* assertions_out,
+    ValidationTracker& validation_tracker,
+    AssertionCounter& assertion_counter) {
+  // CBOR assertion.
+  if (assertion_box->contents.size() == 1 &&
+      assertion_box->contents[0].Holds<CborBox>()) {
+    ValidateCborAssertion(assertion_box->description.label.value_or(""),
+                          assertion_box->contents[0].Get<CborBox>().payload,
+                          manifest_type, assertion_parser, references_validator,
+                          assertions_out, validation_tracker,
+                          assertion_counter);
+    return;
+  }
+  // Thumbnail assertion (EmbeddedFileDescriptionBox + BinaryDataBox).
+  if (assertion_box->contents.size() == 2 &&
+      assertion_box->contents[0].Holds<EmbeddedFileDescriptionBox>() &&
+      assertion_box->contents[1].Holds<BinaryDataBox>()) {
+    std::optional<Assertion> assertion_proto = assertion_parser.ParseThumbnail(
+        assertion_box->description.label.value_or(""),
+        assertion_box->contents[0].Get<EmbeddedFileDescriptionBox>().media_type,
+        assertion_box->contents[1].Get<BinaryDataBox>().payload,
+        assertion_box->contents[0].Get<EmbeddedFileDescriptionBox>().file_name,
+        validation_tracker);
+    if (assertion_proto.has_value()) {
+      assertions_out->Add(*std::move(assertion_proto));
+    }
+    return;
+  }
+  // JSON-LD assertion (e.g., c2pa.metadata).
+  if (assertion_box->contents.size() == 1 &&
+      assertion_box->contents[0].Holds<JsonBox>()) {
+    std::optional<Assertion> assertion_proto = assertion_parser.ParseMetadata(
+        assertion_box->description.label.value_or(""),
+        assertion_box->contents[0].Get<JsonBox>().payload, validation_tracker);
+    if (assertion_proto.has_value()) {
+      assertions_out->Add(*std::move(assertion_proto));
+    }
+    return;
+  }
+
+  // Ignore other types of assertions.
+}
+
+bool ContainsAllZeroBytes(absl::string_view data) {
+  return std::all_of(data.begin(), data.end(), [](char c) { return c == 0; });
+}
+
+bool ContainsAllZeroBytes(const jumbf::ContentBox& content) {
+  if (content.Holds<CborBox>()) {
+    return ContainsAllZeroBytes(content.Get<CborBox>().payload);
+  }
+  if (content.Holds<JsonBox>()) {
+    return ContainsAllZeroBytes(content.Get<JsonBox>().payload);
+  }
+  if (content.Holds<BinaryDataBox>()) {
+    return ContainsAllZeroBytes(content.Get<BinaryDataBox>().payload);
+  }
+  if (content.Holds<EmbeddedFileDescriptionBox>()) {
+    // We don't (yet) require redaction of the file description.
+    return true;
+  }
+  return false;
+}
+
+bool ContainsAllZeroBytes(const jumbf::SuperBox& box) {
+  return std::all_of(
+      box.contents.begin(), box.contents.end(),
+      [](const auto& content) { return ContainsAllZeroBytes(content); });
+}
+
+void ValidateRedactionsAllowed(const Claim& claim,
+                               ValidationTracker& validation_tracker) {
+  // Note that the selfRedacted check is done in ManifestGraph.
+  const AssertionTypeMatcher actions_v1_matcher(kActionsAssertionV1Label);
+  const AssertionTypeMatcher actions_v2_matcher(kActionsAssertionV2Label);
+  for (const auto& redacted_assertion : claim.redacted_assertions()) {
+    absl::string_view label = Basename(redacted_assertion);
+    if (actions_v1_matcher.Matches(label) ||
+        actions_v2_matcher.Matches(label)) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionActionRedacted,
+          {.url = absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter,
+                               label)});
+    }
+  }
+}
+
+// Returns true if `path` is a child of `parent`.
+bool IsChildPath(absl::string_view path, absl::string_view parent) {
+  if (!absl::StartsWith(path, parent)) return false;
+  if (path.size() <= parent.size()) return false;
+  if (path[parent.size()] != '/') return false;
+  return path.find('/', parent.size() + 1) == absl::string_view::npos;
+}
+
+// Returns true if the validation succeeded.
+bool ValidateRedactedAssertion(absl::string_view path,
+                               const jumbf::UriResolver& uri_resolver,
+                               ValidationTracker& validation_tracker) {
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_claim_signature_hash_validation_method
+  // "if the referenced assertion is present and its data consists of anything
+  // other than zero or more 0x00 bytes, the claim shall be rejected with a
+  // failure code of assertion.notRedacted."
+
+  auto box = uri_resolver.ResolvePath(path);
+  if (!box.ok()) {
+    return true;
+  }
+  if (!ContainsAllZeroBytes(**box)) {
+    validation_tracker.RecordFailure(FailureStatusCode::kAssertionNotRedacted,
+                                     {.url = path});
+    return false;
+  }
+  return true;
+}
+
+std::optional<HashedUri> ConvertToHashedUri(
+    const InternalOrExternalHashedUri& uri, absl::string_view default_algorithm,
+    absl::string_view claim_uri, ValidationTracker& validation_tracker) {
+  if (!uri.has_url()) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kHashedUriMissing,
+        {.url = claim_uri, .explanation = "Icon URI is missing"});
+    return std::nullopt;
+  }
+  if (!absl::StartsWith(uri.url(), "self#jumbf=")) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kGoogleHashedUriUnsupported,
+        {.url = claim_uri,
+         .explanation = "Icon URI is pointing to an external resource"});
+    return std::nullopt;
+  }
+  HashedUri hashed_uri;
+  hashed_uri.set_url(uri.url());
+  hashed_uri.set_hash(uri.hash());
+  hashed_uri.set_algorithm(uri.has_algorithm() ? uri.algorithm()
+                                               : default_algorithm);
+  return hashed_uri;
+}
+
+void ValidateAssertion(
+    const HashedUri& hashed_uri, absl::string_view manifest_path,
+    ManifestType manifest_type,
+    const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+    const HashedUriValidator& assertion_uri_validator,
+    const ReferencesValidator& references_validator,
+    absl::string_view assertion_store_path, const Claim& claim,
+    const jumbf::UriResolver& uri_resolver,
+    const AssertionParser& assertion_parser,
+    google::protobuf::RepeatedPtrField<Assertion>* assertions_out,
+    absl::flat_hash_set<std::string>& assertion_labels,
+    ValidationTracker& validation_tracker,
+    AssertionCounter& assertion_counter) {
+  auto assertion_path = jumbf::UriResolver::GetAbsolutePathFromUri(
+      hashed_uri.url(), manifest_path);
+  if (!assertion_path.ok()) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionMissing,
+        {.explanation = "malformed assertion URI"});
+    return;
+  }
+  if (redacted_assertion_paths.contains(*assertion_path)) {
+    if (ValidateRedactedAssertion(*assertion_path, uri_resolver,
+                                  validation_tracker)) {
+      Assertion* redacted_assertion = assertions_out->Add();
+      redacted_assertion->set_label(Basename(*assertion_path));
+      redacted_assertion->mutable_redacted_by_generator();
+    }
+    // Don't treat the redacted assertion as undeclared.
+    assertion_labels.insert(std::string(Basename(*assertion_path)));
+    return;
+  }
+  if (!assertion_uri_validator
+           .Validate(hashed_uri, absl::StrCat("self#jumbf=", *assertion_path),
+                     validation_tracker)
+           .has_value()) {
+    return;  // Failure has been recorded by Validate().
+  }
+  validation_tracker.RecordSuccess(
+      SuccessStatusCode::kAssertionHashedUriMatch,
+      {.url = absl::StrCat("self#jumbf=", *assertion_path)});
+  if (!absl::StartsWith(*assertion_path, manifest_path)) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionOutsideManifest,
+        {.url = claim.label(),
+         .explanation =
+             absl::StrCat("Violated claim assertion: ", *assertion_path)});
+    return;
+  }
+  if (!IsChildPath(*assertion_path, assertion_store_path)) {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kAssertionMissing,
+        {.url = claim.label(),
+         .explanation =
+             absl::StrCat("Violated claim assertion: ", *assertion_path)});
+    return;
+  }
+  auto assertion_box = uri_resolver.ResolvePath(*assertion_path);
+  if (!assertion_box.ok()) {
+    // Can't happen -- assertion_uri_validator.Validate would have failed.
+    DLOG(FATAL) << "unexpected missing assertion: " << assertion_box.status();
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kGoogleInternalError,
+        {.url = claim.label(), .explanation = "unexpected missing assertion"});
+    return;
+  }
+  ValidateAssertionContents(*assertion_box, manifest_type, assertion_parser,
+                            references_validator, assertions_out,
+                            validation_tracker, assertion_counter);
+  assertion_labels.insert(std::string(Basename(*assertion_path)));
+}
+
+void ValidateClaimGeneratorIcon(const Claim& claim,
+                                absl::string_view manifest_path,
+                                const HashedUriValidator& hashed_uri_validator,
+                                ValidationTracker& validation_tracker) {
+  if (!claim.claim_generator_info().has_icon()) {
+    return;
+  }
+  std::optional<HashedUri> icon_hashed_uri = ConvertToHashedUri(
+      claim.claim_generator_info().icon(), claim.default_algorithm(),
+      claim.label(), validation_tracker);
+  if (icon_hashed_uri.has_value()) {
+    auto icon_path = jumbf::UriResolver::GetAbsolutePathFromUri(
+        icon_hashed_uri->url(), manifest_path);
+    if (!icon_path.ok()) {
+      // The only way this can happen is if the icon URI doesn't start with
+      // "self#jumbf=". This would have already been caught by
+      // ConvertToHashedUri.
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kHashedUriMissing,
+          {.url = claim.label(), .explanation = "Icon URI is malformed"});
+    } else if (hashed_uri_validator
+                   .Validate(*icon_hashed_uri, claim.label(),
+                             validation_tracker)
+                   .has_value()) {
+      // No success code for icon URI validation. See
+      // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validation_of_references
+    }
+  }
+}
+
+void ValidateUndeclaredAssertions(
+    absl::string_view assertion_store_path,
+    const absl::flat_hash_set<std::string>& assertion_labels,
+    const jumbf::UriResolver& uri_resolver,
+    ValidationTracker& validation_tracker) {
+  auto assertion_store_box = uri_resolver.ResolvePath(assertion_store_path);
+  if (!assertion_store_box.ok()) {
+    return;
+  }
+  for (const auto& content : (*assertion_store_box)->contents) {
+    if (!content.Holds<SuperBox>()) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionUndeclared,
+          {.explanation =
+               "Assertion store contains a box that is not a superbox."});
+      continue;
+    }
+    const SuperBox& assertion_box = content.Get<SuperBox>();
+    auto assertion_label = assertion_box.description.label.value_or("");
+    if (!assertion_labels.contains(assertion_label)) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kAssertionUndeclared,
+          {.url = absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter,
+                               assertion_label)});
+      continue;
+    }
+  }
+}
+
+}  // namespace
+
+void AssertionValidator::ValidateClaimAssertions(
+    const jumbf::UriResolver& uri_resolver,
+    const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+    Manifest& manifest, ValidationTracker& validation_tracker) const {
+  std::string manifest_path = absl::StrCat("/c2pa/", manifest.label());
+  const Claim& claim = manifest.claim();
+  AssertionCounter assertion_counter;
+  auto assertion_store_path =
+      jumbf::UriResolver::GetAbsolutePath(kAssertionStoreLabel, manifest_path);
+  // Validates claim assertion URIs.
+  HashedUriValidator assertion_uri_validator(std::string(manifest_path),
+                                             uri_resolver);
+  assertion_uri_validator.SetErrorCodes(HashedUriValidator::Codes{
+      .missing = FailureStatusCode::kAssertionMissing,
+      .mismatch = FailureStatusCode::kAssertionHashedUriMismatch});
+  assertion_uri_validator.SetDefaultAlgorithm(claim.default_algorithm());
+  HashedUriValidator hashed_uri_validator(std::string(manifest_path),
+                                          uri_resolver);
+  hashed_uri_validator.SetDefaultAlgorithm(claim.default_algorithm());
+
+  ValidateClaimGeneratorIcon(claim, manifest_path, hashed_uri_validator,
+                             validation_tracker);
+
+  ManifestType manifest_type = manifest.is_update_manifest()
+                                   ? ManifestType::kUpdateManifest
+                                   : ManifestType::kStandardManifest;
+  ReferencesValidator references_validator(
+      &hashed_uri_validator, manifest.label(), &validation_tracker);
+  absl::flat_hash_set<std::string> assertion_labels;
+  assertion_labels.reserve(claim.created_assertions_size() +
+                           claim.gathered_assertions_size());
+  for (const auto& assertions :
+       {claim.created_assertions(), claim.gathered_assertions()}) {
+    for (const HashedUri& hashed_uri : assertions) {
+      ValidateAssertion(
+          hashed_uri, manifest_path, manifest_type, redacted_assertion_paths,
+          assertion_uri_validator, references_validator, assertion_store_path,
+          claim, uri_resolver, assertion_parser_, manifest.mutable_assertions(),
+          assertion_labels, validation_tracker, assertion_counter);
+    }
+  }
+
+  // Validate that this claim's redactions are allowed.
+  ValidateRedactionsAllowed(claim, validation_tracker);
+
+  switch (manifest_type) {
+    case ManifestType::kUpdateManifest:
+      assertion_counter.ValidateUpdateManifestCounts(claim.label(),
+                                                     validation_tracker);
+      break;
+    case ManifestType::kStandardManifest:
+      assertion_counter.ValidateStandardManifestCounts(claim.label(),
+                                                       validation_tracker);
+      break;
+  }
+
+  assertion_counter.ValidateSoftBinding(claim.label(), validation_tracker);
+
+  // Validates any undeclared assertions.
+  ValidateUndeclaredAssertions(assertion_store_path, assertion_labels,
+                               uri_resolver, validation_tracker);
+
+  if (!options_.skip_actions_assertion_validation_for_test) {
+    ActionsAssertionValidator actions_validator;
+    actions_validator.Validate(manifest, validation_tracker);
+  }
+}
+
+}  // namespace credentio
diff --git a/assertion/validator.h b/assertion/validator.h
new file mode 100644
index 0000000..95fa24a
--- /dev/null
+++ b/assertion/validator.h
@@ -0,0 +1,59 @@
+// 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_ASSERTION_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_ASSERTION_VALIDATOR_H_
+
+#include <string>
+
+#include "absl/container/flat_hash_set.h"
+#include "assertion/assertion_parser.h"
+#include "jumbf/uri.h"
+#include "proto/assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+// Validator of assertions referenced by a claim.
+class AssertionValidator {
+ public:
+  struct Options {
+    bool skip_actions_assertion_validation_for_test = false;
+  };
+  AssertionValidator() = default;
+  explicit AssertionValidator(const Options& options) : options_(options) {}
+  virtual ~AssertionValidator() = default;
+
+  // Resolves and validates assertions in a claim, writing the assertions to
+  // the `assertions` field of the manifest.
+  //
+  // `redacted_assertion_paths` contains absolute paths of assertions that have
+  // been declared as redacted by some claim in the manifest store. The
+  // validator skips validating assertions with these paths, after verifying
+  // that they have been properly redacted.
+  virtual void ValidateClaimAssertions(
+      const jumbf::UriResolver& uri_resolver,
+      const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+      Manifest& manifest, ValidationTracker& validation_tracker) const;
+
+ private:
+  const Options options_;
+  const AssertionParser assertion_parser_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASSERTION_VALIDATOR_H_
diff --git a/assertion/validator_test.cc b/assertion/validator_test.cc
new file mode 100644
index 0000000..2e1b28e
--- /dev/null
+++ b/assertion/validator_test.cc
@@ -0,0 +1,3230 @@
+// 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 "assertion/validator.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "absl/types/span.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/uri.h"
+#include "proto/assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/manifest.pb.h"
+#include "testing/cbor_utils.h"
+#include "testing/jumbf_utils.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::cbor::FromJson;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::credentio_testing::PartiallyEqualsProto;
+using ::testing::Contains;
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+using ::testing::SizeIs;
+
+constexpr absl::string_view kManifestLabel1 =
+    "urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4";
+constexpr absl::string_view kManifestLabel2 =
+    "urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA";
+constexpr absl::string_view kManifestLabel3 =
+    "urn:uuid:ABABABAB-0404-0505-0606-FFFFEEEEDDDD";
+
+void AddCreatedAssertion(Claim* claim, jumbf::SuperBox assertion) {
+  absl::string_view algorithm = "sha256";
+  auto* created_assertion = claim->add_created_assertions();
+  created_assertion->set_url(
+      absl::StrCat("self#jumbf=c2pa.assertions/",
+                   assertion.description.label.value_or("UNKNOWN_LABEL")));
+  created_assertion->set_algorithm(algorithm);
+  created_assertion->set_hash(GetHash(algorithm, assertion));
+}
+
+class AssertionValidatorTest : public ::testing::Test {
+ protected:
+  std::string ingredient_assertion_cbor_ = cbor::FromJson(R"json(
+    {
+      "dc:title": "ingredient-title",
+      "dc:format": "ingredient-format",
+      "relationship": "componentOf",
+      "instanceID": "ingredient-instance-id",
+      "data": {
+        "url": "ingredient-data-url",
+        "alg": "ingredient-data-alg",
+        "hash": "b64'aGFzaA=='",
+        "size": 1234567890
+      },
+      "description": "ingredient-description",
+      "informationalURI": "ingredient-informational-uri",
+      "metadata": {
+        "reviewRatings": [
+          {
+            "value": 1,
+            "explanation": "can't dance to it"
+          }
+        ]
+      }
+    }
+    )json");
+  std::string asset_reference_assertion_cbor_ = cbor::FromJson(R"json(
+    {
+      "references": [
+        {
+          "description": "A copy of the asset on the web",
+          "reference": {
+            "uri": "https://some.storage.us/foo"
+          }
+        }
+      ]
+    }
+    )json");
+  std::string data_hash_assertion_cbor_ = cbor::FromJson(R"json(
+    {
+      "alg": "sha256",
+      "pad": "b64'Zm9v'",
+      "hash": "b64'Zm9v'",
+      "name": "JUMBF manifest",
+      "exclusions": [
+        {
+          "start": 9960,
+          "length": 4213
+        },
+        {
+          "start": 14173,
+          "length": 1000
+        }
+      ]
+    })json");
+  std::string embedded_file_ = "fake embedded file contents";
+  std::string metadata_json_ = R"json(
+    {
+      "@context" : {
+        "exif": "http://ns.adobe.com/exif/1.0/",
+        "exifEX": "http://cipa.jp/exif/2.32/",
+        "tiff": "http://ns.adobe.com/tiff/1.0/",
+        "Iptc4xmpCore": "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/",
+        "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/",
+        "dc" : "http://purl.org/dc/elements/1.1/",
+        "photoshop" : "http://ns.adobe.com/photoshop/1.0/"
+      },
+      "photoshop:DateCreated": "Aug 31, 2022",
+      "Iptc4xmpExt:DigitalSourceType": "https://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture",
+      "Iptc4xmpExt:LocationCreated": {
+        "Iptc4xmpExt:City": "San Francisco"
+      },
+      "Iptc4xmpExt:PersonInImage": [
+        "Erika Fictional"
+      ],
+      "Iptc4xmpCore:AltTextAccessibility": "Photo of Erika Fictional standing in front of the Golden Gate Bridge at sunset.",
+      "exif:GPSVersionID": "2.2.0.0",
+      "exif:GPSLatitude": "39,21.102N",
+      "exif:GPSLongitude": "74,26.5737W",
+      "exif:GPSAltitudeRef": 0,
+      "exif:GPSAltitude": "100963/29890",
+      "exif:GPSTimeStamp": "2019-09-22T18:22:57Z",
+      "exif:GPSSpeedRef": "K",
+      "exif:GPSSpeed": "4009/161323",
+      "exif:GPSImgDirectionRef": "T",
+      "exif:GPSImgDirection": "296140/911",
+      "exif:GPSDestBearingRef": "T",
+      "exif:GPSDestBearing": "296140/911",
+      "exif:GPSHPositioningError": "13244/2207",
+      "exif:ExposureTime": "1/100",
+      "exif:FNumber": 4.0,
+      "exif:ColorSpace": 1,
+      "exif:DigitalZoomRatio": 2.0,
+      "tiff:Make": "CameraCompany",
+      "tiff:Model": "Shooter S1",
+      "exifEX:LensMake": "CameraCompany",
+      "exifEX:LensModel": "17.0-35.0 mm",
+      "exifEX:LensSpecification": { "@list": [ 1.55, 4.2, 1.6, 2.4 ] }
+    }
+  )json";
+};
+
+TEST_F(AssertionValidatorTest, ValidateOk) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.metadata"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion"
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3__1",
+                                 ingredient_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromJson("c2pa.metadata", metadata_json_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(
+          EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.hash.data"
+            data_hash {
+              exclusions { start: 9960 length: 4213 }
+              exclusions { start: 14173 length: 1000 }
+              alg: "sha256"
+              hash: "foo"
+              name: "JUMBF manifest"
+            }
+          )pb")),
+          EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              title: "ingredient-title"
+              media_type: "ingredient-format"
+              relationship: "componentOf"
+              instance_id: "ingredient-instance-id"
+              data {
+                url: "ingredient-data-url"
+                algorithm: "ingredient-data-alg"
+                hash: "hash"
+                size: 1234567890
+              }
+              description: "ingredient-description"
+              informational_uri: "ingredient-informational-uri"
+            }
+          )pb")),
+          EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.ingredient.v3__1"
+            ingredient_v3 {
+              title: "ingredient-title"
+              media_type: "ingredient-format"
+              relationship: "componentOf"
+              instance_id: "ingredient-instance-id"
+              data {
+                url: "ingredient-data-url"
+                algorithm: "ingredient-data-alg"
+                hash: "hash"
+                size: 1234567890
+              }
+              description: "ingredient-description"
+              informational_uri: "ingredient-informational-uri"
+            }
+          )pb")),
+          EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.metadata"
+            metadata {
+              value {
+                fields {
+                  key: "@context"
+                  value {
+                    struct_value {
+                      fields {
+                        key: "Iptc4xmpCore"
+                        value {
+                          string_value: "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"
+                        }
+                      }
+                      fields {
+                        key: "Iptc4xmpExt"
+                        value {
+                          string_value: "http://iptc.org/std/Iptc4xmpExt/2008-02-29/"
+                        }
+                      }
+                      fields {
+                        key: "dc"
+                        value {
+                          string_value: "http://purl.org/dc/elements/1.1/"
+                        }
+                      }
+                      fields {
+                        key: "exif"
+                        value { string_value: "http://ns.adobe.com/exif/1.0/" }
+                      }
+                      fields {
+                        key: "exifEX"
+                        value { string_value: "http://cipa.jp/exif/2.32/" }
+                      }
+                      fields {
+                        key: "photoshop"
+                        value {
+                          string_value: "http://ns.adobe.com/photoshop/1.0/"
+                        }
+                      }
+                      fields {
+                        key: "tiff"
+                        value { string_value: "http://ns.adobe.com/tiff/1.0/" }
+                      }
+                    }
+                  }
+                }
+                fields {
+                  key: "Iptc4xmpCore:AltTextAccessibility"
+                  value {
+                    string_value: "Photo of Erika Fictional standing in front of the Golden Gate Bridge at sunset."
+                  }
+                }
+                fields {
+                  key: "Iptc4xmpExt:DigitalSourceType"
+                  value {
+                    string_value: "https://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"
+                  }
+                }
+                fields {
+                  key: "Iptc4xmpExt:LocationCreated"
+                  value {
+                    struct_value {
+                      fields {
+                        key: "Iptc4xmpExt:City"
+                        value { string_value: "San Francisco" }
+                      }
+                    }
+                  }
+                }
+                fields {
+                  key: "Iptc4xmpExt:PersonInImage"
+                  value {
+                    list_value { values { string_value: "Erika Fictional" } }
+                  }
+                }
+                fields {
+                  key: "exif:ColorSpace"
+                  value { number_value: 1 }
+                }
+                fields {
+                  key: "exif:DigitalZoomRatio"
+                  value { number_value: 2 }
+                }
+                fields {
+                  key: "exif:ExposureTime"
+                  value { string_value: "1/100" }
+                }
+                fields {
+                  key: "exif:FNumber"
+                  value { number_value: 4 }
+                }
+                fields {
+                  key: "exif:GPSAltitude"
+                  value { string_value: "100963/29890" }
+                }
+                fields {
+                  key: "exif:GPSAltitudeRef"
+                  value { number_value: 0 }
+                }
+                fields {
+                  key: "exif:GPSDestBearing"
+                  value { string_value: "296140/911" }
+                }
+                fields {
+                  key: "exif:GPSDestBearingRef"
+                  value { string_value: "T" }
+                }
+                fields {
+                  key: "exif:GPSHPositioningError"
+                  value { string_value: "13244/2207" }
+                }
+                fields {
+                  key: "exif:GPSImgDirection"
+                  value { string_value: "296140/911" }
+                }
+                fields {
+                  key: "exif:GPSImgDirectionRef"
+                  value { string_value: "T" }
+                }
+                fields {
+                  key: "exif:GPSLatitude"
+                  value { string_value: "39,21.102N" }
+                }
+                fields {
+                  key: "exif:GPSLongitude"
+                  value { string_value: "74,26.5737W" }
+                }
+                fields {
+                  key: "exif:GPSSpeed"
+                  value { string_value: "4009/161323" }
+                }
+                fields {
+                  key: "exif:GPSSpeedRef"
+                  value { string_value: "K" }
+                }
+                fields {
+                  key: "exif:GPSTimeStamp"
+                  value { string_value: "2019-09-22T18:22:57Z" }
+                }
+                fields {
+                  key: "exif:GPSVersionID"
+                  value { string_value: "2.2.0.0" }
+                }
+                fields {
+                  key: "exifEX:LensMake"
+                  value { string_value: "CameraCompany" }
+                }
+                fields {
+                  key: "exifEX:LensModel"
+                  value { string_value: "17.0-35.0 mm" }
+                }
+                fields {
+                  key: "exifEX:LensSpecification"
+                  value {
+                    struct_value {
+                      fields {
+                        key: "@list"
+                        value {
+                          list_value {
+                            values { number_value: 1.55 }
+                            values { number_value: 4.2 }
+                            values { number_value: 1.6 }
+                            values { number_value: 2.4 }
+                          }
+                        }
+                      }
+                    }
+                  }
+                }
+                fields {
+                  key: "photoshop:DateCreated"
+                  value { string_value: "Aug 31, 2022" }
+                }
+                fields {
+                  key: "tiff:Make"
+                  value { string_value: "CameraCompany" }
+                }
+                fields {
+                  key: "tiff:Model"
+                  value { string_value: "Shooter S1" }
+                }
+              }
+            }
+          )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateActionsRedacted) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    redacted_assertions: "self#jumbf=c2pa.assertions/c2pa.actions"
+  )pb");
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({CreateStandardManifest(kManifestLabel1, {})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionRedacted));
+}
+
+TEST_F(AssertionValidatorTest, ValidateActionsV2Redacted) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    redacted_assertions: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+  )pb");
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({CreateStandardManifest(kManifestLabel1, {})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionRedacted));
+}
+
+TEST_F(AssertionValidatorTest, ValidateActionsMultiInstanceRedacted) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    redacted_assertions: "self#jumbf=c2pa.assertions/c2pa.actions.v2__1"
+  )pb");
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({CreateStandardManifest(kManifestLabel1, {})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionActionRedacted));
+}
+
+TEST_F(AssertionValidatorTest, ValidateUnresolvableUri) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "c2pa.assertions/unresolvable.assertion"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion"
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionMissing));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {}
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateAssertionMissing) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/no.such.assertion"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion"
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionMissing));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {}
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateAssertionMissingNotInAssertionStore) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.somewhere/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion"
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionMissing));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {}
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest,
+       ValidateAssertionMissingNotChildOfAssertionStore) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/somewhere/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion"
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionMissing));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {}
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateAssertionMissingInNestedBox) {
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      jumbf::SuperBox{
+          .description = {.label = "nested"},
+          .contents = {{.payload = CreateAssertionBoxFromCbor(
+                            "c2pa.hash.data", data_hash_assertion_cbor_)}},
+      },
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  auto manifest = ParseTextProtoOrDie<Manifest>(R"pb(
+    label: "urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"
+    claim {
+      created_assertions {
+        url: "self#jumbf=c2pa.assertions/nested/c2pa.hash.data"
+        algorithm: "sha256"
+        hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+      }
+    }
+  )pb");
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionMissing));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateThumbnailAssertion) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.png"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.png"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromEmbeddedFile("c2pa.thumbnail.claim.png",
+                                         "image/png", embedded_file_,
+                                         "claim.thumbnail.png"),
+      CreateAssertionBoxFromEmbeddedFile("c2pa.thumbnail.ingredient.png",
+                                         "image/png", embedded_file_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                    label: "c2pa.hash.data"
+                    data_hash {}
+                  )pb")),
+                  EqualsProto(ParseTextProtoOrDie<Assertion>(
+                      R"pb(label: "c2pa.thumbnail.claim.png"
+                           claim_thumbnail {
+                             media_type: "image/png"
+                             data: "fake embedded file contents"
+                             file_name: "claim.thumbnail.png"
+                           })pb")),
+                  EqualsProto(ParseTextProtoOrDie<Assertion>(
+                      R"pb(label: "c2pa.thumbnail.ingredient.png"
+                           ingredient_thumbnail {
+                             media_type: "image/png"
+                             data: "fake embedded file contents"
+                           })pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateUndeclaredAssertion) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionUndeclared));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {}
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateMalformedAssertion) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  // Add a CBOR box to the assertion store.
+  assertion_store.contents.push_back({.payload = jumbf::CborBox{}});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionUndeclared));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {}
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateSoftBindingMissingForWatermarkedBound) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created"
+        },
+        {
+          "action": "c2pa.watermarked.bound"
+        }
+      ]
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionSoftBindingMissing));
+}
+
+TEST_F(AssertionValidatorTest, ValidateSoftBindingPresentForWatermarkedBound) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.soft-binding"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created"
+        },
+        {
+          "action": "c2pa.watermarked.bound"
+        }
+      ]
+    }
+    )json");
+
+  std::string soft_binding_cbor = cbor::FromJson(R"json(
+    {
+      "alg": "soft-algorithm",
+      "blocks": [
+        {
+          "scope": {
+            "timespan": {
+              "start": 100,
+              "end": 200
+            }
+          },
+          "value": "b64'dmFsdWU='"
+        }
+      ]
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+      CreateAssertionBoxFromCbor("c2pa.soft-binding", soft_binding_cbor),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateSoftBindingMissingForWatermarked) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created"
+        },
+        {
+          "action": "c2pa.watermarked"
+        }
+      ]
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionActionSoftBindingMissing));
+}
+
+TEST_F(AssertionValidatorTest, ValidateSoftBindingPresentForWatermarked) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.soft-binding"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created"
+        },
+        {
+          "action": "c2pa.watermarked"
+        }
+      ]
+    }
+    )json");
+  std::string soft_binding_cbor = cbor::FromJson(R"json(
+    {
+      "alg": "soft-algorithm",
+      "blocks": [
+        {
+          "scope": {
+            "timespan": {
+              "start": 100,
+              "end": 200
+            }
+          },
+          "value": "b64'dmFsdWU='"
+        }
+      ]
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+      CreateAssertionBoxFromCbor("c2pa.soft-binding", soft_binding_cbor),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateInaccessibleAssertion) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "https://foo.bar/my_assertion"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionMissing));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {}
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateInvalidCbor) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", "not valid cbor"),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionCborInvalid));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateAssertionAbsolutePath) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=/c2pa/urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4/c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {}
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateAssertionOutsideManifest) {
+  // This URI points to the manifest with kManifestLabel2.
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {}),
+       CreateStandardManifest(kManifestLabel2, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionOutsideManifest));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateInvalidAlgorithm) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "not_an_algo"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAlgorithmUnsupported));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateInvalidHash) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "not_a_hash"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionHashedUriMismatch));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateInvalidHashSha384) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha384"
+      hash: "not_a_hash"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionHashedUriMismatch));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateInvalidHashSha512) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha512"
+      hash: "not_a_hash"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionHashedUriMismatch));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateActionsUnknown) {
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.unknown"}]
+    }
+    )json");
+
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.actions", actions_cbor),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailures(),
+      ContainsFailure(FailureStatusCode::kGoogleAssertionActionUnknownAction));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateActionsV2Unknown) {
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.unknown"}]
+    }
+    )json");
+
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailures(),
+      ContainsFailure(FailureStatusCode::kGoogleAssertionActionUnknownAction));
+  EXPECT_THAT(manifest.assertions(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, ValidateActionsV2Created) {
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.created"}]
+    }
+    )json");
+
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                    label: "c2pa.actions.v2"
+                    actions { actions { action: "c2pa.created" } }
+                  )pb")),
+                  PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(
+                      R"pb(label: "c2pa.hash.data")pb"))));
+}
+
+TEST_F(AssertionValidatorTest, InvalidStandardManifestNoHardBinding) {
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.created"}]
+    }
+    )json");
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimHardBindingsMissing));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, InvalidStandardManifestMultipleHardBindings) {
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.created"}]
+    }
+    )json");
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionMultipleHardBindings));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                    label: "c2pa.actions.v2"
+                    actions { actions { action: "c2pa.created" } }
+                  )pb")),
+                  PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(
+                      R"pb(label: "c2pa.hash.data")pb")),
+                  PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(
+                      R"pb(label: "c2pa.hash.data")pb"))));
+}
+
+TEST_F(AssertionValidatorTest,
+       InvalidStandardManifestMultipleParentIngredients) {
+  std::string ingredient_cbor = cbor::FromJson(R"json(
+    {
+      "relationship": "parentOf",
+      "instanceID": "ingredient-instance-id"
+    }
+    )json");
+  std::string actions_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.created"}]
+    }
+    )json");
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3", ingredient_cbor),
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3__1", ingredient_cbor),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kManifestMultipleParents));
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                    label: "c2pa.actions.v2"
+                    actions { actions { action: "c2pa.created" } }
+                  )pb")),
+                  PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(
+                      R"pb(label: "c2pa.hash.data")pb")),
+                  PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(
+                      R"pb(label: "c2pa.ingredient.v3")pb")),
+                  PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(
+                      R"pb(label: "c2pa.ingredient.v3__1")pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidUpdateManifest) {
+  jumbf::SuperBox std_assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  auto std_manifest =
+      CreateStandardManifest(kManifestLabel1, {std_assertion_store});
+
+  std::string ingredient_assertion_cbor = cbor::FromJson(absl::Substitute(
+      R"json(
+      {
+        "relationship": "parentOf",
+        "instanceID": "ingredient-instance-id",
+        "c2pa_manifest": {
+          "url": "self#jumbf=/c2pa/$0",
+          "alg": "sha256",
+          "hash": "b64'$1'"
+        }
+      }
+      )json",
+      kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest))));
+  jumbf::SuperBox ingredient_assertion_box = CreateAssertionBoxFromCbor(
+      "c2pa.ingredient.v3", ingredient_assertion_cbor);
+  std::string actions_assertion_cbor = cbor::FromJson(absl::Substitute(
+      R"json(
+    {
+      "actions": [{
+        "action": "c2pa.opened",
+        "parameters": {
+          "ingredients": [{
+            "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+            "alg": "sha256",
+            "hash": "b64'$0'"
+          }]
+        }
+      }]
+    }
+    )json",
+      absl::Base64Escape(GetHash("sha256", ingredient_assertion_box))));
+
+  jumbf::SuperBox actions_assertion_box =
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor);
+  jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox(
+      {actions_assertion_box, ingredient_assertion_box});
+  jumbf::SuperBox update_manifest =
+      CreateUpdateManifest(kManifestLabel2, {update_assertion_store});
+
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({std_manifest, update_manifest});
+  Claim update_claim;
+  AddCreatedAssertion(&update_claim, actions_assertion_box);
+  AddCreatedAssertion(&update_claim, ingredient_assertion_box);
+
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_is_update_manifest(true);
+  manifest.set_label(kManifestLabel2);
+  *manifest.mutable_claim() = update_claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+}
+
+TEST_F(AssertionValidatorTest, InvalidUpdateManifestMultipleParentIngredients) {
+  jumbf::SuperBox std_assertion_store_1 = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox std_assertion_store_2 = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  auto std_manifest_1 =
+      CreateStandardManifest(kManifestLabel1, {std_assertion_store_1});
+  auto std_manifest_2 =
+      CreateStandardManifest(kManifestLabel2, {std_assertion_store_2});
+
+  std::string actions_assertion_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.opened"}]
+    }
+    )json");
+
+  std::string ingredient_assertion_1_cbor = cbor::FromJson(absl::Substitute(
+      R"json(
+    {
+      "relationship": "parentOf",
+      "instanceID": "ingredient-instance-id-1",
+      "c2pa_manifest": {
+        "url": "self#jumbf=/c2pa/$0",
+        "alg": "sha256",
+        "hash": "b64'$1'"
+      }
+    }
+    )json",
+      kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest_1))));
+  std::string ingredient_assertion_2_cbor = cbor::FromJson(absl::Substitute(
+      R"json(
+    {
+      "relationship": "parentOf",
+      "instanceID": "ingredient-instance-id-2",
+      "c2pa_manifest": {
+        "url": "self#jumbf=/c2pa/$0",
+        "alg": "sha256",
+        "hash": "b64'$1'"
+      }
+    }
+    )json",
+      kManifestLabel2, absl::Base64Escape(GetHash("sha256", std_manifest_2))));
+  jumbf::SuperBox actions_assertion_box =
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor);
+  jumbf::SuperBox ingredient_assertion_1_box = CreateAssertionBoxFromCbor(
+      "c2pa.ingredient.v3", ingredient_assertion_1_cbor);
+  jumbf::SuperBox ingredient_assertion_2_box = CreateAssertionBoxFromCbor(
+      "c2pa.ingredient.v3__1", ingredient_assertion_2_cbor);
+  jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox({
+      actions_assertion_box,
+      ingredient_assertion_1_box,
+      ingredient_assertion_2_box,
+  });
+  jumbf::SuperBox update_manifest =
+      CreateUpdateManifest(kManifestLabel3, {update_assertion_store});
+
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({std_manifest_1, std_manifest_2, update_manifest});
+  Claim update_claim;
+  AddCreatedAssertion(&update_claim, actions_assertion_box);
+  AddCreatedAssertion(&update_claim, ingredient_assertion_1_box);
+  AddCreatedAssertion(&update_claim, ingredient_assertion_2_box);
+
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_is_update_manifest(true);
+  manifest.set_label(kManifestLabel3);
+  *manifest.mutable_claim() = update_claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kManifestUpdateWrongParents));
+}
+
+TEST_F(AssertionValidatorTest, InvalidUpdateManifestHardBindingsPresent) {
+  jumbf::SuperBox std_assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  auto std_manifest =
+      CreateStandardManifest(kManifestLabel1, {std_assertion_store});
+
+  std::string actions_assertion_cbor = cbor::FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.opened"}]
+    }
+    )json");
+
+  std::string ingredient_assertion_cbor = cbor::FromJson(absl::Substitute(
+      R"json(
+    {
+      "relationship": "parentOf",
+      "instanceID": "ingredient-instance-id",
+      "c2pa_manifest": {
+        "url": "self#jumbf=/c2pa/$0",
+        "alg": "sha256",
+        "hash": "b64'$1'"
+      }
+    }
+    )json",
+      kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest))));
+  jumbf::SuperBox actions_assertion_box =
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor);
+  jumbf::SuperBox ingredient_assertion_box = CreateAssertionBoxFromCbor(
+      "c2pa.ingredient.v3", ingredient_assertion_cbor);
+  jumbf::SuperBox binding_assertion_box =
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_);
+  jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox({
+      actions_assertion_box,
+      ingredient_assertion_box,
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox update_manifest =
+      CreateUpdateManifest(kManifestLabel2, {update_assertion_store});
+
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({std_manifest, update_manifest});
+  Claim update_claim;
+  AddCreatedAssertion(&update_claim, actions_assertion_box);
+  AddCreatedAssertion(&update_claim, ingredient_assertion_box);
+  AddCreatedAssertion(&update_claim, binding_assertion_box);
+
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_is_update_manifest(true);
+  manifest.set_label(kManifestLabel2);
+  *manifest.mutable_claim() = update_claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kManifestUpdateInvalid));
+}
+
+TEST_F(AssertionValidatorTest, InvalidUpdateManifestDisallowedAction) {
+  jumbf::SuperBox std_assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  auto std_manifest =
+      CreateStandardManifest(kManifestLabel1, {std_assertion_store});
+
+  // c2pa.cropped is not allowed in an update manifest.
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.opened"}, {"action": "c2pa.cropped"}]
+    }
+    )json");
+
+  std::string ingredient_assertion_cbor = FromJson(absl::Substitute(
+      R"json(
+    {
+      "relationship": "parentOf",
+      "instanceID": "ingredient-instance-id",
+      "c2pa_manifest": {
+        "url": "self#jumbf=/c2pa/$0",
+        "alg": "sha256",
+        "hash": "b64'$1'"
+      }
+    }
+    )json",
+      kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest))));
+  jumbf::SuperBox actions_assertion_box =
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor);
+  jumbf::SuperBox ingredient_assertion_box = CreateAssertionBoxFromCbor(
+      "c2pa.ingredient.v3", ingredient_assertion_cbor);
+  jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox(
+      {actions_assertion_box, ingredient_assertion_box});
+  jumbf::SuperBox update_manifest =
+      CreateUpdateManifest(kManifestLabel2, {update_assertion_store});
+
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({std_manifest, update_manifest});
+  Claim update_claim;
+  AddCreatedAssertion(&update_claim, actions_assertion_box);
+  AddCreatedAssertion(&update_claim, ingredient_assertion_box);
+
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_is_update_manifest(true);
+  manifest.set_label(kManifestLabel2);
+  *manifest.mutable_claim() = update_claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kManifestUpdateInvalid));
+}
+
+TEST_F(AssertionValidatorTest, InvalidUpdateManifestNoIngredients) {
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.opened"}]
+    }
+    )json");
+  jumbf::SuperBox actions_assertion_box =
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor);
+  jumbf::SuperBox update_assertion_store =
+      CreateAssertionStoreBox({actions_assertion_box});
+  jumbf::SuperBox update_manifest =
+      CreateUpdateManifest(kManifestLabel2, {update_assertion_store});
+
+  jumbf::SuperBox manifest_store = CreateManifestStore({update_manifest});
+  Claim update_claim;
+  AddCreatedAssertion(&update_claim, actions_assertion_box);
+
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_is_update_manifest(true);
+  manifest.set_label(kManifestLabel2);
+  *manifest.mutable_claim() = update_claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kManifestUpdateWrongParents));
+}
+
+TEST_F(AssertionValidatorTest, InvalidUpdateManifestWrongIngredientRelation) {
+  jumbf::SuperBox std_assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  auto std_manifest =
+      CreateStandardManifest(kManifestLabel1, {std_assertion_store});
+
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [{"action": "c2pa.opened"}]
+    }
+    )json");
+
+  // Relationship here is "componentOf" instead of "parentOf".
+  std::string ingredient_assertion_cbor = FromJson(absl::Substitute(
+      R"json(
+    {
+      "relationship": "componentOf",
+      "instanceID": "ingredient-instance-id",
+      "c2pa_manifest": {
+        "url": "self#jumbf=/c2pa/$0",
+        "alg": "sha256",
+        "hash": "b64'$1'"
+      }
+    }
+    )json",
+      kManifestLabel1, absl::Base64Escape(GetHash("sha256", std_manifest))));
+  jumbf::SuperBox actions_assertion_box =
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor);
+  jumbf::SuperBox ingredient_assertion_box = CreateAssertionBoxFromCbor(
+      "c2pa.ingredient.v3", ingredient_assertion_cbor);
+  jumbf::SuperBox update_assertion_store = CreateAssertionStoreBox(
+      {actions_assertion_box, ingredient_assertion_box});
+  jumbf::SuperBox update_manifest =
+      CreateUpdateManifest(kManifestLabel2, {update_assertion_store});
+
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({std_manifest, update_manifest});
+  Claim update_claim;
+  AddCreatedAssertion(&update_claim, actions_assertion_box);
+  AddCreatedAssertion(&update_claim, ingredient_assertion_box);
+
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_is_update_manifest(true);
+  manifest.set_label(kManifestLabel2);
+  *manifest.mutable_claim() = update_claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kManifestUpdateWrongParents));
+}
+
+TEST_F(AssertionValidatorTest, IngredientV1Unsupported) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json(
+    {
+      "dc:title": "ingredient-title",
+      "dc:format": "ingredient-format",
+      "relationship": "componentOf",
+      "instanceID": "ingredient-instance-id",
+      "c2pa_manifest": {
+        "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA",
+        "alg": "sha256",
+        "hash": "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='"
+      },
+      "thumbnail": {
+        "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg",
+        "alg": "sha256",
+        "hash": "b64'Jl/aF6NGEbFTPYooH/aA3FeRsM4KEcJbNeEcjnVoVQk='"
+      }
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient", ingredient_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox thumbnail_box = {
+      .description =
+          {
+              .label = "c2pa.thumbnail.ingredient_1.jpg",
+          },
+      .raw_bytes = "b64'Zm9v'",
+  };
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store}),
+       CreateStandardManifest(kManifestLabel2,
+                              {assertion_store, thumbnail_box})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "com.google.unsupportedSpecVersion"
+        explanation: "c2pa.ingredient (v1) and c2pa.ingredient.v2 assertions are not supported"
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, IngredientV2Unsupported) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json(
+    {
+      "dc:title": "ingredient-title",
+      "dc:format": "ingredient-format",
+      "relationship": "componentOf",
+      "c2pa_manifest": {
+        "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA",
+        "alg": "sha256",
+        "hash": "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='"
+      },
+      "thumbnail": {
+        "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg",
+        "alg": "sha256",
+        "hash": "b64'Jl/aF6NGEbFTPYooH/aA3FeRsM4KEcJbNeEcjnVoVQk='"
+      }
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v2",
+                                 ingredient_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox thumbnail_box = {
+      .description =
+          {
+              .label = "c2pa.thumbnail.ingredient_1.jpg",
+          },
+      .raw_bytes = "b64'Zm9v'",
+  };
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store}),
+       CreateStandardManifest(kManifestLabel2,
+                              {assertion_store, thumbnail_box})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "com.google.unsupportedSpecVersion"
+        explanation: "c2pa.ingredient (v1) and c2pa.ingredient.v2 assertions are not supported"
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateIngredientV3) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json(
+    {
+      "dc:title": "ingredient-title",
+      "dc:format": "ingredient-format",
+      "relationship": "componentOf",
+      "activeManifest": {
+        "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA",
+        "alg": "sha256",
+        "hash": "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='"
+      },
+      "claimSignature": {
+          "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.signature",
+          "alg": "sha256",
+          "hash": "b64'Jl/aF6NGEbFTPYooH/aA3FeRsM4KEcJbNeEcjnVoVQk='"
+      },
+      "thumbnail": {
+        "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg",
+        "alg": "sha256",
+        "hash": "b64'Jl/aF6NGEbFTPYooH/aA3FeRsM4KEcJbNeEcjnVoVQk='"
+      },
+      "validationResults": {
+        "activeManifest": {
+          "success": [{"code": "assertion.dataHash.match"}],
+          "informational": [],
+          "failure": []
+        }
+      }
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox thumbnail_box = {
+      .description =
+          {
+              .label = "c2pa.thumbnail.ingredient_1.jpg",
+          },
+      .raw_bytes = "b64'Zm9v'",
+  };
+  jumbf::SuperBox claim_signature_box = {
+      .description =
+          {
+              .label = "c2pa.signature",
+          },
+      .raw_bytes = "b64'Zm9v'",
+  };
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store}),
+       CreateStandardManifest(kManifestLabel2, {assertion_store, thumbnail_box,
+                                                claim_signature_box})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  ASSERT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(
+          PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.hash.data"
+            data_hash {}
+          )pb")),
+          PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              active_manifest {
+                url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA"
+              }
+              thumbnail {
+                url: "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg"
+              }
+            }
+          )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateIngredientV3Failed) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json(
+    {
+      "dc:title": "ingredient-title",
+      "dc:format": "ingredient-format",
+      "relationship": "componentOf",
+      "activeManifest": {
+        "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA",
+        "alg": "sha256",
+        "hash": "b64'Zm9v'"
+      },
+      "thumbnail": {
+        "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA/c2pa.thumbnail.ingredient_1.jpg",
+        "alg": "sha256",
+        "hash": "b64'BAD/HASH'"
+      },
+      "validationResults": {}
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox thumbnail_box = {
+      .description =
+          {
+              .label = "c2pa.thumbnail.ingredient_1.jpg",
+          },
+      .raw_bytes = "b64'Zm9v'",
+  };
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store}),
+       CreateStandardManifest(kManifestLabel2,
+                              {assertion_store, thumbnail_box})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMismatch));
+}
+
+TEST_F(AssertionValidatorTest, ValidateIngredientV3FailedBadRelationship) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json(
+    {
+      "relationship": "someOtherRelationship"
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.ingredient.malformed"
+        explanation: "Unrecognized ingredient relationship"
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateIngredientV3FailedMissingRelationship) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json({})json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.ingredient.malformed"
+        explanation: "Missing ingredient relationship"
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest,
+       ValidateIngredientV3FailedBothActiveManifestAndDigitalSourceType) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json(
+    {
+      "relationship": "componentOf",
+      "activeManifest": {
+        "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+        "alg": "sha256",
+        "hash": "b64'Zm9v'"
+      },
+      "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+    }
+  )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.ingredient.malformed"
+        explanation: "Ingredient assertion must not contain both activeManifest and digitalSourceType"
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateIngredientV3FailedNoHardBindingSuccess) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json({
+    "relationship": "componentOf",
+    "activeManifest": {
+      "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA",
+      "alg": "sha256",
+      "hash": "b64'Zm9v'"
+    },
+    "validationResults": {
+      "activeManifest": {
+        "success": [{"code": "assertion.hashedURI.match"}],
+        "informational": [],
+        "failure": []
+      }
+    }
+  })json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.ingredient.malformed"
+        explanation: "no hard binding check recorded"
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest,
+       ValidateIngredientV3UpdateManifestFailedNoValidationResults) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string ingredient_assertion_cbor = FromJson(R"json({
+    "relationship": "parentOf",
+    "activeManifest": {
+      "url": "self#jumbf=/c2pa/urn:uuid:AFAFAFAF-0101-0202-0303-ABCDEFFEDCBA",
+      "alg": "sha256",
+      "hash": "b64'Zm9v'"
+    },
+    "validationResults": {}
+  })json");
+  jumbf::SuperBox assertion_store =
+      CreateAssertionStoreBox({CreateAssertionBoxFromCbor(
+          "c2pa.ingredient.v3", ingredient_assertion_cbor)});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateUpdateManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  manifest.set_is_update_manifest(true);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.ingredient.malformed"
+        explanation: "no validation successes or failures recorded"
+      )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateActionsV2Ingredients) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created",
+          "parameters" : {
+            "ingredients" : [
+              {
+                "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+                "alg": "sha256",
+                "hash" : "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='"
+              },
+              {
+                "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1",
+                "alg": "sha256",
+                "hash" : "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='"
+              }
+            ]
+          }
+        }
+      ]
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3__1",
+                                 ingredient_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  ASSERT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(
+      manifest.assertions(),
+      ElementsAre(
+          PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.hash.data"
+            data_hash {}
+          )pb")),
+          PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                  }
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                  }
+                }
+              }
+            }
+          )pb")),
+          PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.ingredient.v3"
+          )pb")),
+          PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+            label: "c2pa.ingredient.v3__1"
+          )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidateActionsV2IngredientsFailed) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient__1"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient__2"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  std::string actions_assertion_cbor = FromJson(R"json(
+    {
+      "actions": [
+        {
+          "action": "c2pa.created",
+          "parameters" : {
+            "ingredients" : [
+              {
+                "url": "self#jumbf=c2pa.assertions/c2pa.ingredient__1",
+                "alg": "sha256",
+                "hash" : "b64'EliDwzUVTZC6NXyCMndU824N4FKwkHxkx68nnsVWYfg='"
+              },
+              {
+                "url": "self#jumbf=c2pa.assertions/c2pa.ingredient__2",
+                "alg": "sha256",
+                "hash" : "b64'Zm9v'"
+              }
+            ]
+          }
+        }
+      ]
+    }
+    )json");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.actions.v2", actions_assertion_cbor),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient__1",
+                                 ingredient_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient__2",
+                                 ingredient_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMismatch));
+}
+
+TEST_F(AssertionValidatorTest, MissingAssertionFailure) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.metadata"
+      algorithm: "sha256"
+      hash: "hash-is-irrelevant"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionMissing));
+  EXPECT_THAT(manifest.assertions(), SizeIs(1));
+}
+
+TEST_F(AssertionValidatorTest, MissingRedactedAssertionOk) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.metadata"
+      algorithm: "sha256"
+      hash: "hash-is-irrelevant"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  std::string redacted_assertion_path = absl::Substitute(
+      "/c2pa/$0/c2pa.assertions/c2pa.metadata", kManifestLabel1);
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(
+      uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path},
+      manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(manifest.assertions(), SizeIs(2));
+  EXPECT_THAT(manifest.assertions(),
+              Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.hash.data"
+                data_hash {}
+              )pb"))));
+  EXPECT_THAT(manifest.assertions(),
+              Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.metadata"
+                redacted_by_generator {}
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, RedactedAssertionWithZeroJsonDataOk) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.metadata"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromJson("c2pa.metadata", "\0\0\0"),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  std::string redacted_assertion_path = absl::Substitute(
+      "/c2pa/$0/c2pa.assertions/c2pa.metadata", kManifestLabel1);
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(
+      uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path},
+      manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(manifest.assertions(), SizeIs(2));
+  EXPECT_THAT(manifest.assertions(),
+              Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.metadata"
+                redacted_by_generator {}
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, RedactedAssertionWithNonzeroJsonDataFails) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.metadata"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromJson("c2pa.metadata", metadata_json_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  std::string redacted_assertion_path = absl::Substitute(
+      "/c2pa/$0/c2pa.assertions/c2pa.metadata", kManifestLabel1);
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(
+      uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path},
+      manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionNotRedacted));
+  EXPECT_THAT(manifest.assertions(), SizeIs(1));
+}
+
+TEST_F(AssertionValidatorTest, RedactedAssertionWithZeroCborDataOk) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "hash-is-irrelevant"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3", "\0\0\0"),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  std::string redacted_assertion_path = absl::Substitute(
+      "/c2pa/$0/c2pa.assertions/c2pa.ingredient.v3", kManifestLabel1);
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(
+      uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path},
+      manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(manifest.assertions(), SizeIs(2));
+  EXPECT_THAT(manifest.assertions(),
+              Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.ingredient.v3"
+                redacted_by_generator {}
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, RedactedAssertionWithNonZeroCborDataFails) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.ingredient.v3",
+                                 ingredient_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  std::string redacted_assertion_path = absl::Substitute(
+      "/c2pa/$0/c2pa.assertions/c2pa.ingredient.v3", kManifestLabel1);
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(
+      uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path},
+      manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionNotRedacted));
+  EXPECT_THAT(manifest.assertions(), SizeIs(1));
+}
+
+TEST_F(AssertionValidatorTest, RedactedAssertionWithZeroEmbeddedFileDataOk) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.png"
+      algorithm: "sha256"
+      hash: "hash-is-irrelevant"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromEmbeddedFile("c2pa.thumbnail.claim.png",
+                                         "image/png", "\0\0\0",
+                                         "claim.thumbnail.png"),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  std::string redacted_assertion_path = absl::Substitute(
+      "/c2pa/$0/c2pa.assertions/c2pa.thumbnail.claim.png", kManifestLabel1);
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(
+      uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path},
+      manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(manifest.assertions(), SizeIs(2));
+  EXPECT_THAT(manifest.assertions(),
+              Contains(PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.thumbnail.claim.png"
+                redacted_by_generator {}
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest,
+       RedactedAssertionWithNonZeroEmbeddedFileDataFails) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.png"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromEmbeddedFile("c2pa.thumbnail.claim.png",
+                                         "image/png", embedded_file_,
+                                         "claim.thumbnail.png"),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+  std::string redacted_assertion_path = absl::Substitute(
+      "/c2pa/$0/c2pa.assertions/c2pa.thumbnail.claim.png", kManifestLabel1);
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(
+      uri_resolver, /*redacted_assertion_paths=*/{redacted_assertion_path},
+      manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionNotRedacted));
+  EXPECT_THAT(manifest.assertions(), SizeIs(1));
+}
+
+TEST_F(AssertionValidatorTest, MissingIconUriFails) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    claim_generator_info {
+      name: "generator_icon"
+      icon { algorithm: "sha256" hash: "hash-is-irrelevant" }
+    }
+    label: "c2pa.claim.v2"
+  )pb");
+  jumbf::SuperBox assertion_store =
+      CreateAssertionStoreBox({CreateAssertionBoxFromCbor(
+          "c2pa.hash.data", data_hash_assertion_cbor_)});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "hashedUri.missing"
+        url: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.claim.v2"
+        explanation: "Icon URI is missing"
+      )pb"))));
+  EXPECT_THAT(manifest.assertions(),
+              ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.hash.data"
+                data_hash {
+                  exclusions { start: 9960 length: 4213 }
+                  exclusions { start: 14173 length: 1000 }
+                  alg: "sha256"
+                  hash: "foo"
+                  name: "JUMBF manifest"
+                }
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ExternalIconUriFails) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    claim_generator_info {
+      name: "generator_icon"
+      icon {
+        url: "https://example.com/icon.png"
+        algorithm: "sha256"
+        hash: "hash-is-irrelevant"
+      }
+    }
+    label: "c2pa.claim.v2"
+  )pb");
+  jumbf::SuperBox assertion_store =
+      CreateAssertionStoreBox({CreateAssertionBoxFromCbor(
+          "c2pa.hash.data", data_hash_assertion_cbor_)});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "com.google.hashedUri.unsupported"
+        url: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.claim.v2"
+        explanation: "Icon URI is pointing to an external resource"
+      )pb"))));
+  EXPECT_THAT(manifest.assertions(),
+              ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.hash.data"
+                data_hash {
+                  exclusions { start: 9960 length: 4213 }
+                  exclusions { start: 14173 length: 1000 }
+                  alg: "sha256"
+                  hash: "foo"
+                  name: "JUMBF manifest"
+                }
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, IconUriPointsToMissingLocationFails) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    claim_generator_info {
+      name: "generator_icon"
+      icon {
+        url: "self#jumbf=c2pa.missing"
+        algorithm: "sha256"
+        hash: "hash-is-irrelevant"
+      }
+    }
+    label: "c2pa.claim.v2"
+  )pb");
+  jumbf::SuperBox assertion_store =
+      CreateAssertionStoreBox({CreateAssertionBoxFromCbor(
+          "c2pa.hash.data", data_hash_assertion_cbor_)});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "hashedUri.missing"
+        url: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.claim.v2"
+      )pb"))));
+  EXPECT_THAT(manifest.assertions(),
+              ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.hash.data"
+                data_hash {
+                  exclusions { start: 9960 length: 4213 }
+                  exclusions { start: 14173 length: 1000 }
+                  alg: "sha256"
+                  hash: "foo"
+                  name: "JUMBF manifest"
+                }
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidIconUri) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    claim_generator_info {
+      name: "generator_icon"
+      icon {
+        url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+        algorithm: "sha256"
+        hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+      }
+    }
+    label: "c2pa.claim.v2"
+  )pb");
+  jumbf::SuperBox assertion_store =
+      CreateAssertionStoreBox({CreateAssertionBoxFromCbor(
+          "c2pa.hash.data", data_hash_assertion_cbor_)});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(manifest.assertions(),
+              ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.hash.data"
+                data_hash {
+                  exclusions { start: 9960 length: 4213 }
+                  exclusions { start: 14173 length: 1000 }
+                  alg: "sha256"
+                  hash: "foo"
+                  name: "JUMBF manifest"
+                }
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, ValidIconUriFallbacksToDefaultAlgorithm) {
+  Claim claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    claim_generator_info {
+      name: "generator_icon"
+      icon {
+        url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+        hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+      }
+    }
+    default_algorithm: "sha256"
+    label: "c2pa.claim.v2"
+  )pb");
+  jumbf::SuperBox assertion_store =
+      CreateAssertionStoreBox({CreateAssertionBoxFromCbor(
+          "c2pa.hash.data", data_hash_assertion_cbor_)});
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_THAT(manifest.assertions(),
+              ElementsAre(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.hash.data"
+                data_hash {
+                  exclusions { start: 9960 length: 4213 }
+                  exclusions { start: 14173 length: 1000 }
+                  alg: "sha256"
+                  hash: "foo"
+                  name: "JUMBF manifest"
+                }
+              )pb"))));
+}
+
+TEST_F(AssertionValidatorTest, FailsWhenMultipleHardBindings) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data__1"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.hash.data__1",
+                                 data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(
+      t.GetFailures(),
+      ContainsFailure(FailureStatusCode::kAssertionMultipleHardBindings));
+  EXPECT_THAT(manifest.assertions(), SizeIs(2));
+}
+
+TEST_F(AssertionValidatorTest,
+       PassesWhenAdditionalHardAssertionsArePartsOfMultiAssetHash) {
+  auto claim = ParseTextProtoOrDie<Claim>(R"pb(
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data.part"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.data"
+      algorithm: "sha256"
+      hash: "\x12\x58\x83\xc3\x35\x15\x4d\x90\xba\x35\x7c\x82\x32\x77\x54\xf3\x6e\x0d\xe0\x52\xb0\x90\x7c\x64\xc7\xaf\x27\x9e\xc5\x56\x61\xf8"
+    }
+  )pb");
+  jumbf::SuperBox assertion_store = CreateAssertionStoreBox({
+      CreateAssertionBoxFromCbor("c2pa.hash.data.part",
+                                 data_hash_assertion_cbor_),
+      CreateAssertionBoxFromCbor("c2pa.hash.data", data_hash_assertion_cbor_),
+  });
+  jumbf::SuperBox manifest_store = CreateManifestStore(
+      {CreateStandardManifest(kManifestLabel1, {assertion_store})});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  AssertionValidator validator;
+  TestValidationTracker t;
+
+  Manifest manifest;
+  manifest.set_label(kManifestLabel1);
+  *manifest.mutable_claim() = claim;
+  validator.ValidateClaimAssertions(uri_resolver, {}, manifest, t.tracker());
+
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  EXPECT_THAT(manifest.assertions(), SizeIs(2));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/async/BUILD b/async/BUILD
new file mode 100644
index 0000000..12227c3
--- /dev/null
+++ b/async/BUILD
@@ -0,0 +1,76 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "executor",
+    hdrs = ["executor.h"],
+    deps = [
+        "@abseil-cpp//absl/functional:any_invocable",
+    ],
+)
+
+cc_library(
+    name = "async_context",
+    hdrs = ["async_context.h"],
+    deps = [
+        ":cancellation_token",
+        ":executor",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
+
+cc_test(
+    name = "async_context_test",
+    srcs = ["async_context_test.cc"],
+    deps = [
+        ":async_context",
+        ":executor",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "cancellation_token",
+    srcs = ["cancellation_token.cc"],
+    hdrs = ["cancellation_token.h"],
+    deps = [
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/synchronization",
+    ],
+)
+
+cc_test(
+    name = "cancellation_token_test",
+    srcs = ["cancellation_token_test.cc"],
+    deps = [
+        ":cancellation_token",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/async/async_context.h b/async/async_context.h
new file mode 100644
index 0000000..c25eef2
--- /dev/null
+++ b/async/async_context.h
@@ -0,0 +1,123 @@
+// 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_ASYNC_ASYNC_CONTEXT_H_
+#define THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "async/cancellation_token.h"
+#include "async/executor.h"
+
+namespace credentio {
+
+// Provides an execution context for asynchronous operations.
+class AsyncContext {
+ public:
+  explicit AsyncContext() = default;
+
+  // The caller retains ownership of `executor`, which must outlive the
+  // `AsyncContext`.
+  explicit AsyncContext(Executor* absl_nullable executor)
+      : executor_(executor) {}
+
+  Executor* absl_nullable executor() const { return executor_; }
+
+  void Cancel() { cancellation_source_.Cancel(); }
+
+  CancellationToken GetCancellationToken() const {
+    return cancellation_source_.GetToken();
+  }
+
+ private:
+  Executor* absl_nullable executor_ = nullptr;
+  CancellationSource cancellation_source_;
+};
+
+// Helper class to resolve a callback on the given AsyncContext using a
+// StatusOr<T>. If the context is cancelled, the callback is executed
+// inline with a CancelledError. If the context (or its executor) is missing, it
+// resolves inline.
+template <typename T>
+class AsyncResolver {
+ public:
+  AsyncResolver(AsyncContext context,
+                absl::AnyInvocable<void(absl::StatusOr<T>) &&> cb)
+      : context_(std::move(context)), cb_(std::move(cb)) {}
+
+  AsyncResolver(AsyncResolver&&) = default;
+  AsyncResolver& operator=(AsyncResolver&&) = default;
+  AsyncResolver(const AsyncResolver&) = delete;
+  AsyncResolver& operator=(const AsyncResolver&) = delete;
+
+  void Return(absl::StatusOr<T> result) {
+    if (context_.GetCancellationToken().IsCancelled()) {
+      std::move(cb_)(absl::CancelledError("AsyncContext cancelled"));
+      return;
+    }
+    if (context_.executor()) {
+      context_.executor()->Schedule(
+          [cb = std::move(cb_), r = std::move(result)]() mutable {
+            std::move(cb)(std::move(r));
+          });
+      return;
+    }
+    std::move(cb_)(std::move(result));
+  }
+
+ private:
+  AsyncContext context_;
+  absl::AnyInvocable<void(absl::StatusOr<T>) &&> cb_;
+};
+
+// Specialization for void callbacks (absl::Status).
+template <>
+class AsyncResolver<void> {
+ public:
+  AsyncResolver(AsyncContext context,
+                absl::AnyInvocable<void(absl::Status) &&> cb)
+      : context_(std::move(context)), cb_(std::move(cb)) {}
+
+  AsyncResolver(AsyncResolver&&) = default;
+  AsyncResolver& operator=(AsyncResolver&&) = default;
+  AsyncResolver(const AsyncResolver&) = delete;
+  AsyncResolver& operator=(const AsyncResolver&) = delete;
+
+  void Return(absl::Status status) {
+    if (context_.GetCancellationToken().IsCancelled()) {
+      std::move(cb_)(absl::CancelledError("AsyncContext cancelled"));
+      return;
+    }
+    if (context_.executor()) {
+      context_.executor()->Schedule(
+          [cb = std::move(cb_), s = std::move(status)]() mutable {
+            std::move(cb)(std::move(s));
+          });
+      return;
+    }
+    std::move(cb_)(std::move(status));
+  }
+
+ private:
+  AsyncContext context_;
+  absl::AnyInvocable<void(absl::Status) &&> cb_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_
diff --git a/async/async_context_test.cc b/async/async_context_test.cc
new file mode 100644
index 0000000..bb9e123
--- /dev/null
+++ b/async/async_context_test.cc
@@ -0,0 +1,157 @@
+// 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 "async/async_context.h"
+
+#include <utility>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "async/executor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+
+class MockExecutor : public Executor {
+ public:
+  MOCK_METHOD(void, Schedule, (absl::AnyInvocable<void() &&> task), (override));
+};
+
+TEST(AsyncContextTest, ExecutesOnExecutorIfContextProvided) {
+  MockExecutor mock_executor;
+  AsyncContext context(&mock_executor);
+
+  bool task_scheduled = false;
+  EXPECT_CALL(mock_executor, Schedule(testing::_))
+      .WillOnce([&task_scheduled](absl::AnyInvocable<void() &&> task) {
+        task_scheduled = true;
+        std::move(task)();
+      });
+
+  bool task_run = false;
+  AsyncResolver<int>(context, [&task_run](absl::StatusOr<int> status) {
+    task_run = true;
+    EXPECT_THAT(status, IsOkAndHolds(42));
+  }).Return(42);
+
+  EXPECT_TRUE(task_scheduled);
+  EXPECT_TRUE(task_run);
+}
+
+TEST(AsyncContextTest, ExecutesInlineIfContextIsNull) {
+  bool task_run = false;
+  AsyncResolver<int>(AsyncContext(nullptr), [&task_run](
+                                                absl::StatusOr<int> status) {
+    task_run = true;
+    EXPECT_THAT(status, IsOkAndHolds(42));
+  }).Return(42);
+
+  EXPECT_TRUE(task_run);
+}
+
+TEST(AsyncContextTest, ExecutesInlineIfExecutorIsNull) {
+  AsyncContext context(nullptr);
+
+  bool task_run = false;
+  AsyncResolver<int>(context, [&task_run](absl::StatusOr<int> status) {
+    task_run = true;
+    EXPECT_THAT(status, IsOkAndHolds(42));
+  }).Return(42);
+
+  EXPECT_TRUE(task_run);
+}
+
+TEST(AsyncContextTest, CancellationTokensCheck) {
+  AsyncContext context(nullptr);
+  EXPECT_FALSE(context.GetCancellationToken().IsCancelled());
+  context.Cancel();
+  EXPECT_TRUE(context.GetCancellationToken().IsCancelled());
+}
+
+TEST(AsyncContextTest, ResolveOnAsyncContextStatusCancelledWhenCancelled) {
+  MockExecutor mock_executor;
+  AsyncContext context(&mock_executor);
+  context.Cancel();
+
+  EXPECT_CALL(mock_executor, Schedule(testing::_)).Times(0);
+
+  bool task_run = false;
+  AsyncResolver<void>(context, [&task_run](absl::Status status) {
+    task_run = true;
+    EXPECT_THAT(status, StatusIs(absl::StatusCode::kCancelled));
+  }).Return(absl::OkStatus());
+
+  EXPECT_TRUE(task_run);
+}
+
+TEST(AsyncContextTest, ResolveOnAsyncContextStatusOrCancelledWhenCancelled) {
+  MockExecutor mock_executor;
+  AsyncContext context(&mock_executor);
+  context.Cancel();
+
+  EXPECT_CALL(mock_executor, Schedule(testing::_)).Times(0);
+
+  bool task_run = false;
+  AsyncResolver<int>(context, [&task_run](absl::StatusOr<int> status) {
+    task_run = true;
+    EXPECT_THAT(status.status(), StatusIs(absl::StatusCode::kCancelled));
+  }).Return(42);
+
+  EXPECT_TRUE(task_run);
+}
+
+TEST(AsyncContextTest, ExecutesOnExecutorIfContextProvidedVoid) {
+  MockExecutor mock_executor;
+  AsyncContext context(&mock_executor);
+
+  bool task_scheduled = false;
+  EXPECT_CALL(mock_executor, Schedule(testing::_))
+      .WillOnce([&task_scheduled](absl::AnyInvocable<void() &&> task) {
+        task_scheduled = true;
+        std::move(task)();
+      });
+
+  bool task_run = false;
+  AsyncResolver<void>(context, [&task_run](absl::Status status) {
+    task_run = true;
+    ABSL_EXPECT_OK(status);
+  }).Return(absl::OkStatus());
+
+  EXPECT_TRUE(task_scheduled);
+  EXPECT_TRUE(task_run);
+}
+
+TEST(AsyncContextTest, ExecutesInlineIfExecutorIsNullVoid) {
+  AsyncContext context(nullptr);
+
+  bool task_run = false;
+  AsyncResolver<void>(context, [&task_run](absl::Status status) {
+    task_run = true;
+    ABSL_EXPECT_OK(status);
+  }).Return(absl::OkStatus());
+
+  EXPECT_TRUE(task_run);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/async/cancellation_token.cc b/async/cancellation_token.cc
new file mode 100644
index 0000000..a402265
--- /dev/null
+++ b/async/cancellation_token.cc
@@ -0,0 +1,73 @@
+// 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 "async/cancellation_token.h"
+
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/synchronization/mutex.h"
+
+namespace credentio {
+
+void CancellationState::Cancel() {
+  std::vector<absl::AnyInvocable<void()>> to_run;
+
+  {
+    absl::MutexLock lock(mutex_);
+    if (cancelled_) return;
+    cancelled_ = true;
+
+    to_run.reserve(callbacks_.size());
+    for (auto& pair : callbacks_) {
+      to_run.push_back(std::move(pair.second));
+    }
+    callbacks_.clear();
+  }
+
+  // Execute callbacks without holding the lock to avoid deadlocks.
+  for (auto& cb : to_run) {
+    if (cb) {
+      cb();
+    }
+  }
+}
+
+uint64_t CancellationState::RegisterCallback(absl::AnyInvocable<void()> cb) {
+  {
+    absl::MutexLock lock(mutex_);
+    if (!cancelled_) {
+      uint64_t id = next_id_++;
+      callbacks_[id] = std::move(cb);
+      return id;
+    }
+  }
+
+  // If already cancelled, execute immediately and return 0 (no unregister
+  // needed).
+  if (cb) {
+    cb();
+  }
+  return 0;
+}
+
+void CancellationState::DeregisterCallback(uint64_t id) {
+  absl::MutexLock lock(mutex_);
+  callbacks_.erase(id);
+}
+
+}  // namespace credentio
diff --git a/async/cancellation_token.h b/async/cancellation_token.h
new file mode 100644
index 0000000..1a50e59
--- /dev/null
+++ b/async/cancellation_token.h
@@ -0,0 +1,130 @@
+// 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_ASYNC_CANCELLATION_TOKEN_H_
+#define THIRD_PARTY_CREDENTIO_ASYNC_CANCELLATION_TOKEN_H_
+
+#include <cstdint>
+#include <memory>
+#include <utility>
+
+#include "absl/base/thread_annotations.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/synchronization/mutex.h"
+
+namespace credentio {
+
+class CancellationToken;
+
+// Internal shared state for cancellation.
+class CancellationState {
+ public:
+  CancellationState() = default;
+
+  bool IsCancelled() const {
+    absl::MutexLock lock(mutex_);
+    return cancelled_;
+  }
+
+  void Cancel();
+
+  // Returns >0 registration ID if successfully registered.
+  // Returns 0 if already cancelled (and executes callback immediately inline).
+  uint64_t RegisterCallback(absl::AnyInvocable<void()> cb);
+
+  void DeregisterCallback(uint64_t id);
+
+ private:
+  mutable absl::Mutex mutex_;
+  bool cancelled_ ABSL_GUARDED_BY(mutex_) = false;
+  uint64_t next_id_ ABSL_GUARDED_BY(mutex_) = 1;
+  absl::flat_hash_map<uint64_t, absl::AnyInvocable<void()>> callbacks_
+      ABSL_GUARDED_BY(mutex_);
+};
+
+// Represents a read-only view of a cancellation state.
+class CancellationToken {
+ public:
+  CancellationToken() = default;
+
+  bool IsCancelled() const {
+    if (!state_) return false;
+    return state_->IsCancelled();
+  }
+
+  // Copyable and movable.
+  CancellationToken(const CancellationToken&) = default;
+  CancellationToken& operator=(const CancellationToken&) = default;
+  CancellationToken(CancellationToken&&) = default;
+  CancellationToken& operator=(CancellationToken&&) = default;
+
+ private:
+  friend class CancellationSource;
+  friend class CancellationCallback;
+
+  explicit CancellationToken(std::shared_ptr<CancellationState> state)
+      : state_(std::move(state)) {}
+
+  std::shared_ptr<CancellationState> state_;
+};
+
+// The source of cancellation.
+class CancellationSource {
+ public:
+  CancellationSource() : state_(std::make_shared<CancellationState>()) {}
+
+  void Cancel() {
+    if (state_) {
+      state_->Cancel();
+    }
+  }
+
+  CancellationToken GetToken() const { return CancellationToken(state_); }
+
+ private:
+  std::shared_ptr<CancellationState> state_;
+};
+
+// Represents a registration of a callback against a CancellationToken.
+// When this object falls out of scope, the callback is automatically
+// deregistered.
+class CancellationCallback {
+ public:
+  CancellationCallback(CancellationToken token, absl::AnyInvocable<void()> cb)
+      : state_(std::move(token.state_)) {
+    if (state_) {
+      id_ = state_->RegisterCallback(std::move(cb));
+    }
+  }
+
+  ~CancellationCallback() {
+    if (state_ && id_ != 0) {
+      state_->DeregisterCallback(id_);
+    }
+  }
+
+  // Non-copyable, non-movable.
+  CancellationCallback(const CancellationCallback&) = delete;
+  CancellationCallback& operator=(const CancellationCallback&) = delete;
+
+ private:
+  std::shared_ptr<CancellationState> state_;
+  uint64_t id_ = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASYNC_CANCELLATION_TOKEN_H_
diff --git a/async/cancellation_token_test.cc b/async/cancellation_token_test.cc
new file mode 100644
index 0000000..dff17b4
--- /dev/null
+++ b/async/cancellation_token_test.cc
@@ -0,0 +1,119 @@
+// 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 "async/cancellation_token.h"
+
+#include <optional>
+
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+TEST(CancellationTokenTest, IsCancelledFalseInitially) {
+  CancellationSource source;
+  CancellationToken token = source.GetToken();
+  EXPECT_FALSE(token.IsCancelled());
+}
+
+TEST(CancellationTokenTest, IsCancelledTrueAfterCancel) {
+  CancellationSource source;
+  CancellationToken token = source.GetToken();
+  source.Cancel();
+  EXPECT_TRUE(token.IsCancelled());
+}
+
+TEST(CancellationTokenTest, CallbackExecutedOnCancel) {
+  CancellationSource source;
+  CancellationToken token = source.GetToken();
+
+  bool executed = false;
+  CancellationCallback callback(token, [&executed]() { executed = true; });
+
+  EXPECT_FALSE(executed);
+  source.Cancel();
+  EXPECT_TRUE(executed);
+}
+
+TEST(CancellationTokenTest, CallbackNotExecutedIfDeregistered) {
+  CancellationSource source;
+  CancellationToken token = source.GetToken();
+
+  bool executed = false;
+  {
+    CancellationCallback callback(token, [&executed]() { executed = true; });
+  }  // callback destroyed here
+
+  source.Cancel();
+  EXPECT_FALSE(executed);
+}
+
+TEST(CancellationTokenTest, CallbackExecutedImmediatelyIfAlreadyCancelled) {
+  CancellationSource source;
+  CancellationToken token = source.GetToken();
+  source.Cancel();
+
+  bool executed = false;
+  CancellationCallback callback(token, [&executed]() { executed = true; });
+
+  // Execute happens inline during registration
+  EXPECT_TRUE(executed);
+}
+
+TEST(CancellationTokenTest, MultipleCallbacks) {
+  CancellationSource source;
+  CancellationToken token = source.GetToken();
+
+  bool ex1 = false;
+  bool ex2 = false;
+
+  CancellationCallback cb1(token, [&ex1]() { ex1 = true; });
+  CancellationCallback cb2(token, [&ex2]() { ex2 = true; });
+
+  source.Cancel();
+  EXPECT_TRUE(ex1);
+  EXPECT_TRUE(ex2);
+}
+
+TEST(CancellationTokenTest, UncancellableToken) {
+  CancellationToken token;  // Default constructed
+  EXPECT_FALSE(token.IsCancelled());
+
+  bool executed = false;
+  CancellationCallback callback(token, [&executed]() { executed = true; });
+
+  // Can never be cancelled
+  EXPECT_FALSE(executed);
+}
+
+// Ensure the token can outlive the source safely.
+TEST(CancellationTokenTest, TokenOutlivesSource) {
+  std::optional<CancellationToken> token;
+  {
+    CancellationSource source;
+    token = source.GetToken();
+  }
+
+  // Checking IsCancelled should be safe and return false
+  EXPECT_FALSE(token->IsCancelled());
+
+  // Registering callback should be safe (and it will never fire)
+  bool executed = false;
+  CancellationCallback callback(*token, [&executed]() { executed = true; });
+  EXPECT_FALSE(executed);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/async/executor.h b/async/executor.h
new file mode 100644
index 0000000..a7523e0
--- /dev/null
+++ b/async/executor.h
@@ -0,0 +1,34 @@
+// 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_ASYNC_EXECUTOR_H_
+#define THIRD_PARTY_CREDENTIO_ASYNC_EXECUTOR_H_
+
+#include "absl/functional/any_invocable.h"
+
+namespace credentio {
+
+// Simple interface for scheduling asynchronous tasks.
+class Executor {
+ public:
+  virtual ~Executor() = default;
+
+  // Schedules the given task for asynchronous execution.
+  virtual void Schedule(absl::AnyInvocable<void() &&> task) = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_ASYNC_EXECUTOR_H_
diff --git a/bindings/BUILD b/bindings/BUILD
new file mode 100644
index 0000000..995fea2
--- /dev/null
+++ b/bindings/BUILD
@@ -0,0 +1,438 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "input_hasher",
+    srcs = ["input_hasher.cc"],
+    hdrs = ["input_hasher.h"],
+    deps = [
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "input_hasher_test",
+    srcs = ["input_hasher_test.cc"],
+    deps = [
+        ":input_hasher",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "binding_hasher",
+    srcs = ["binding_hasher.cc"],
+    hdrs = ["binding_hasher.h"],
+    deps = [
+        ":input_hasher",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "binding_hasher_test",
+    srcs = ["binding_hasher_test.cc"],
+    deps = [
+        ":binding_hasher",
+        ":input_hasher",
+        "//crypto/default:hasher",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "merkle",
+    srcs = ["merkle.cc"],
+    hdrs = ["merkle.h"],
+    deps = [
+        "//crypto:hash",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@protobuf//:protobuf_lite",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "merkle_test",
+    srcs = ["merkle_test.cc"],
+    deps = [
+        ":merkle",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "bmff_exclusion_checker",
+    srcs = ["bmff_exclusion_checker.cc"],
+    hdrs = ["bmff_exclusion_checker.h"],
+    deps = [
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "hard_binding_validator",
+    hdrs = ["hard_binding_validator.h"],
+    deps = [
+        "//formats:asset_byte_info",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "merkle_validator",
+    srcs = ["merkle_validator.cc"],
+    hdrs = ["merkle_validator.h"],
+    deps = [
+        ":input_hasher",
+        ":merkle",
+        "//cbor",
+        "//cbor:parse",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//formats/bmff:box_header",
+        "//proto:bmff_based_hash_assertion_cbor_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//utils:riegeli",
+        "//utils:status_tracker",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "boxes_hash_validator",
+    srcs = ["boxes_hash_validator.cc"],
+    hdrs = ["boxes_hash_validator.h"],
+    deps = [
+        "//constants:status_codes",
+        "//crypto:algorithms",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//utils:status_tracker",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_test(
+    name = "boxes_hash_validator_test",
+    srcs = ["boxes_hash_validator_test.cc"],
+    deps = [
+        ":boxes_hash_validator",
+        "//constants:status_codes",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:proto_test_utils",
+        "//testing:test_status_tracker",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@protobuf//:differencer",
+    ],
+)
+
+cc_test(
+    name = "merkle_validator_test",
+    srcs = ["merkle_validator_test.cc"],
+    deps = [
+        ":merkle_validator",
+        "//constants:status_codes",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//formats/bmff:test_utils",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//testing:cbor_utils",
+        "//testing:proto_test_utils",
+        "//utils:status_tracker",
+        "//validator:tracker",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "bmff_binding_hasher",
+    srcs = ["bmff_binding_hasher.cc"],
+    hdrs = ["bmff_binding_hasher.h"],
+    deps = [
+        ":binding_hasher",
+        "//formats/bmff:box_header",
+        "//formats/bmff:box_matcher",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//utils:byte_writers",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "bmff_binding_hasher_test",
+    srcs = ["bmff_binding_hasher_test.cc"],
+    deps = [
+        ":binding_hasher",
+        ":bmff_binding_hasher",
+        "//formats/bmff:test_utils",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "boxes_hash_hard_binding_validator",
+    srcs = ["boxes_hash_hard_binding_validator.cc"],
+    hdrs = ["boxes_hash_hard_binding_validator.h"],
+    deps = [
+        ":binding_hasher",
+        ":boxes_hash_validator",
+        ":hard_binding_validator",
+        "//constants:status_codes",
+        "//formats:asset_box",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//proto:assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//utils:dual_status_tracker",
+        "//utils:status_tracker",
+        "//validator:result",
+        "//validator:validation_result_internal",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "data_hash_hard_binding_validator",
+    srcs = ["data_hash_hard_binding_validator.cc"],
+    hdrs = ["data_hash_hard_binding_validator.h"],
+    deps = [
+        ":binding_hasher",
+        ":hard_binding_validator",
+        "//constants:status_codes",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//proto:assertion_cc_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//utils:dual_status_tracker",
+        "//utils:status_tracker",
+        "//validator:result",
+        "//validator:validation_result_internal",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "bmff_hash_hard_binding_validator",
+    srcs = ["bmff_hash_hard_binding_validator.cc"],
+    hdrs = ["bmff_hash_hard_binding_validator.h"],
+    deps = [
+        ":binding_hasher",
+        ":bmff_binding_hasher",
+        ":bmff_exclusion_checker",
+        ":hard_binding_validator",
+        ":merkle_validator",
+        "//constants:status_codes",
+        "//formats:asset_byte_info",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:multi_asset_hash_assertion_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//utils:dual_status_tracker",
+        "//utils:status_tracker",
+        "//validator:result",
+        "//validator:validation_result_internal",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "collection_data_hash_hard_binding_validator",
+    srcs = ["collection_data_hash_hard_binding_validator.cc"],
+    hdrs = ["collection_data_hash_hard_binding_validator.h"],
+    deps = [
+        ":binding_hasher",
+        ":hard_binding_validator",
+        ":input_hasher",
+        "//constants:status_codes",
+        "//crypto:algorithms",
+        "//formats:asset_byte_info",
+        "//formats/zip:constants",
+        "//formats/zip:reader",
+        "//proto:assertion_cc_proto",
+        "//proto:collection_data_hash_assertion_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//utils:dual_status_tracker",
+        "//utils:status_tracker",
+        "//validator:result",
+        "//validator:validation_result_internal",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "validator",
+    srcs = ["validator.cc"],
+    hdrs = ["validator.h"],
+    deps = [
+        ":bmff_hash_hard_binding_validator",
+        ":boxes_hash_hard_binding_validator",
+        ":collection_data_hash_hard_binding_validator",
+        ":data_hash_hard_binding_validator",
+        "//constants:status_codes",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:core_registry",
+        "//formats:format",
+        "//formats:registry",
+        "//formats/bmff:assessor",
+        "//formats/bmff:box_header",
+        "//jumbf:uri",
+        "//proto:assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:collection_data_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:multi_asset_hash_assertion_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//utils:dual_status_tracker",
+        "//utils:status_tracker",
+        "//utils:two_stage_status_tracker",
+        "//utils:uri",
+        "//validator:result",
+        "//validator:validation_result_internal",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
diff --git a/bindings/binding_hasher.cc b/bindings/binding_hasher.cc
new file mode 100644
index 0000000..4d6c96e
--- /dev/null
+++ b/bindings/binding_hasher.cc
@@ -0,0 +1,142 @@
+// 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/binding_hasher.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "bindings/input_hasher.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+absl_nullable std::unique_ptr<BindingHasher> BindingHasher::Create(
+    absl::string_view alg, std::vector<Chunk> chunks,
+    BindingHasherTracker& tracker) {
+  auto hasher = InputHasher::Create(alg);
+  if (!hasher.ok()) {
+    tracker.RecordAlgorithmUnsupported();
+    return nullptr;
+  }
+  return std::make_unique<BindingHasher>(*std::move(hasher), std::move(chunks));
+}
+
+std::optional<std::string> BindingHasher::Digest(riegeli::Reader& input,
+                                                 BindingHasherTracker& tracker,
+                                                 uint64_t start_offset,
+                                                 int64_t end_offset) {
+  if (!input.SupportsSize() || !input.Size().has_value()) {
+    tracker.RecordMalformed();
+    return std::nullopt;
+  }
+  const uint64_t file_size = *input.Size();
+
+  if (end_offset < 0) {
+    end_offset = file_size;
+  }
+  if (end_offset > file_size) {
+    tracker.RecordMalformed();
+    return std::nullopt;
+  }
+
+  auto update_input = [&](uint64_t offset, int64_t size) -> bool {
+    if (size <= 0) {
+      return true;
+    }
+    const absl::Status status = hasher_->Update(input, offset, size);
+    if (!status.ok()) {
+      tracker.RecordGeneralError(status);
+      return false;
+    }
+    return true;
+  };
+
+  uint64_t pos = start_offset;
+  std::optional<Chunk::Op> last_op = std::nullopt;
+  for (const auto& chunk : chunks_) {
+    // Check Op compatibility.
+    if (last_op.has_value() && (chunk.op == Chunk::Op::kInclusion) !=
+                                   (*last_op == Chunk::Op::kInclusion)) {
+      tracker.RecordMalformed();
+      return std::nullopt;
+    }
+    last_op = chunk.op;
+
+    // Check out of file or negative length chunk.
+    if (pos >= end_offset || chunk.length < 0 || chunk.offset > end_offset ||
+        chunk.offset < pos) {
+      tracker.RecordMalformed();
+      return std::nullopt;
+    }
+
+    // Advance to the chunk offset.
+    const uint64_t next_pos = std::min<uint64_t>(chunk.offset, end_offset);
+    if (chunk.op != Chunk::Op::kInclusion) {
+      if (!update_input(pos, next_pos - pos)) {
+        return std::nullopt;
+      }
+    }
+    pos = next_pos;
+    if (pos >= end_offset && chunk.op != Chunk::Op::kExclusion) {
+      // Appending to the end of file is not allowed.
+      tracker.RecordMalformed();
+      return std::nullopt;
+    }
+
+    // Advance through the chunk.
+    int64_t chunk_size = chunk.length;
+    const int64_t file_left_size = end_offset - pos;
+    switch (chunk.op) {
+      case Chunk::Op::kExclusion:
+      case Chunk::Op::kInclusion:
+        if (chunk_size > file_left_size) {
+          tracker.RecordMismatch();
+          return std::nullopt;
+        }
+        if (chunk_size == 0) {
+          chunk_size = file_left_size;
+        }
+        if (chunk.op == Chunk::Op::kInclusion) {
+          if (!update_input(pos, chunk_size)) {
+            return std::nullopt;
+          }
+        }
+        break;
+      case Chunk::Op::kInsertion:
+        // Ignore the chunk size.
+        chunk_size = 0;
+        hasher_->Update(chunk.additional_data);
+        break;
+    }
+    pos += chunk_size;
+  }
+
+  if (!last_op.has_value() || *last_op != Chunk::Op::kInclusion) {
+    // Advance the rest of file.
+    if (!update_input(pos, end_offset - pos)) {
+      return std::nullopt;
+    }
+  }
+  return hasher_->Digest();
+}
+}  // namespace credentio
diff --git a/bindings/binding_hasher.h b/bindings/binding_hasher.h
new file mode 100644
index 0000000..36d4056
--- /dev/null
+++ b/bindings/binding_hasher.h
@@ -0,0 +1,108 @@
+// 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_BINDINGS_BINDING_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_BINDING_HASHER_H_
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "bindings/input_hasher.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// A tracker to record the errors during the process of digesting a hard binding
+// (e.g., a BMFF file).
+class BindingHasherTracker {
+ public:
+  virtual ~BindingHasherTracker() = default;
+  virtual void RecordMismatch() = 0;
+  virtual void RecordMalformed() = 0;
+  virtual void RecordAlgorithmUnsupported() = 0;
+  virtual void RecordGeneralError(absl::Status status) = 0;
+};
+
+// A BindingHasherTracker implementation that produces an absl::Status.
+class StatusBindingHasherTracker : public BindingHasherTracker {
+ public:
+  void RecordMismatch() override {
+    status_.Update(absl::InvalidArgumentError("mismatch"));
+  }
+  void RecordMalformed() override {
+    status_.Update(absl::InvalidArgumentError("malformed"));
+  }
+  void RecordAlgorithmUnsupported() override {
+    status_.Update(absl::InvalidArgumentError("algorithm unsupported"));
+  }
+  void RecordGeneralError(absl::Status status) override {
+    status_.Update(status);
+  }
+  absl::Status status() const { return status_; }
+
+ private:
+  absl::Status status_ = absl::OkStatus();
+};
+
+// A hasher to digest a file according to the C2PA Spec. In particular, this
+// hasher supports excluding, including or inserting data.
+class BindingHasher {
+ public:
+  struct Chunk {
+    // Defines the type of operation; whether to exclude, insert or include the
+    // chunk into the hasher.
+    enum class Op { kExclusion, kInsertion, kInclusion };
+    Op op;            // Type of operation, like exclusion or insertion.
+    uint64_t offset;  // The absolute offset of the content to be digested.
+    // The length of the chunk. If zero and the Op is kExclusion or kInclusion,
+    // the chunk includes the rest of the content. When the Op is kInsertion,
+    // the length is ignored, and the whole additional_data will be inserted.
+    int64_t length;
+    // Additional data for insertion. Only suitable for small data, like the
+    // offset of a root box, which is as an 8-byte integer in the big-endian
+    // format when digesting a BMFF file by the C2PA Spec (`c2pa.hash.bmff.v2`).
+    std::string additional_data;
+  };
+
+  // The chunks must be ordered by offset and non-overlapping.
+  BindingHasher(std::unique_ptr<InputHasher> hasher, std::vector<Chunk> chunks)
+      : hasher_(std::move(hasher)), chunks_(std::move(chunks)) {}
+
+  // Digest the input file. The start_offset is the offset of the first byte
+  // of the data to digest. The end_offset is the offset with respect to the
+  // start of the entire file of the last byte of the data to digest, -1 means
+  // the end of the file.
+  std::optional<std::string> Digest(riegeli::Reader& input,
+                                    BindingHasherTracker& tracker,
+                                    uint64_t start_offset = 0,
+                                    int64_t end_offset = -1);
+  static absl_nullable std::unique_ptr<BindingHasher> Create(
+      absl::string_view alg, std::vector<Chunk> chunks,
+      BindingHasherTracker& tracker);
+
+ private:
+  std::unique_ptr<InputHasher> hasher_;
+  std::vector<Chunk> chunks_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_BINDING_HASHER_H_
diff --git a/bindings/binding_hasher_test.cc b/bindings/binding_hasher_test.cc
new file mode 100644
index 0000000..c8b5fa2
--- /dev/null
+++ b/bindings/binding_hasher_test.cc
@@ -0,0 +1,425 @@
+// 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/binding_hasher.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "bindings/input_hasher.h"
+#include "crypto/default/hasher.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::credentio::BindingHasher::Chunk::Op::kExclusion;
+using ::credentio::BindingHasher::Chunk::Op::kInclusion;
+using ::credentio::BindingHasher::Chunk::Op::kInsertion;
+using ::testing::HasSubstr;
+using ::testing::IsNull;
+using ::testing::NotNull;
+using ::testing::Optional;
+
+class ErrorHasher : public InputHasher {
+ public:
+  absl::Status Update(riegeli::Reader& input, uint64_t offset,
+                      uint64_t length) override {
+    return absl::InvalidArgumentError("error");
+  }
+  void Update(absl::string_view data) override {}
+  std::string Digest() override { return ""; }
+};
+
+std::string Sha256Digest(absl::string_view contents) {
+  auto hasher = CreateHasher("sha256");
+  if (!hasher.ok()) return "";
+  (*hasher)->Update(contents);
+  return (*hasher)->Digest();
+}
+
+TEST(BmffHasher, OkayEntireContent) {
+  StatusBindingHasherTracker tracker;
+  auto input = "0123456789";
+  auto hasher = BindingHasher::Create("sha256", /*chunks=*/{}, tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  EXPECT_THAT(result, Optional(Sha256Digest("0123456789")));
+  EXPECT_THAT(tracker.status(), IsOk());
+}
+
+TEST(BmffHasher, OkayLargeContent) {
+  StatusBindingHasherTracker tracker;
+  std::string input;
+  for (int i = 0; i < 2000; ++i) {
+    absl::StrAppend(&input, "abcdefg");
+  }
+
+  auto hasher = BindingHasher::Create("sha256", /*chunks=*/{}, tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  EXPECT_THAT(result, Optional(Sha256Digest(input)));
+  EXPECT_THAT(tracker.status(), IsOk());
+}
+
+TEST(BmffHasher, OkayExclusions) {
+  StatusBindingHasherTracker tracker;
+  auto input = "0123456789";
+
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kExclusion, .offset = 1, .length = 1},
+                             {.op = kExclusion, .offset = 3, .length = 2}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  EXPECT_THAT(result, Optional(Sha256Digest("0256789")));
+  EXPECT_THAT(tracker.status(), IsOk());
+}
+
+TEST(BmffHasher, OkayExcludingTheRestOfFile) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kExclusion, .offset = 1, .length = 1},
+                             {.op = kExclusion, .offset = 3, .length = 0}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  EXPECT_THAT(result, Optional(Sha256Digest("02")));
+  EXPECT_THAT(tracker.status(), IsOk());
+}
+
+TEST(BmffHasher, OkayInsertions) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create(
+      "sha256",
+      {{.op = kInsertion, .offset = 1, .additional_data = "AAA"},
+       {.op = kInsertion, .offset = 7, .additional_data = "BBB"}},
+      tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  EXPECT_THAT(result, Optional(Sha256Digest("0AAA123456BBB789")));
+  EXPECT_THAT(tracker.status(), IsOk());
+}
+
+TEST(BmffHasher, OkayInsertionsAndIgnoreChunkSize) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create(
+      "sha256",
+      {{.op = kInsertion, .offset = 1, .length = 100, .additional_data = "AAA"},
+       {.op = kInsertion, .offset = 7, .additional_data = "BBB"}},
+      tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  EXPECT_THAT(result, Optional(Sha256Digest("0AAA123456BBB789")));
+  EXPECT_THAT(tracker.status(), IsOk());
+}
+
+TEST(BmffHasher, OkayMixingExclusionsAndInsertions) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create(
+      "sha256",
+      {{.op = kExclusion, .offset = 1, .length = 1},
+       {.op = kInsertion, .offset = 2, .additional_data = "AAA"},
+       {.op = kExclusion, .offset = 3, .length = 2},
+       {.op = kInsertion, .offset = 7, .additional_data = "BBB"}},
+      tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  EXPECT_THAT(result, Optional(Sha256Digest("0AAA256BBB789")));
+  EXPECT_THAT(tracker.status(), IsOk());
+}
+
+TEST(BmffHasher, OkayInclusions) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kInclusion, .offset = 1, .length = 1},
+                             {.op = kInclusion, .offset = 3, .length = 2},
+                             {.op = kInclusion,
+                              .offset = 6,
+                              /* .length = 0 */}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  EXPECT_THAT(result, Optional(Sha256Digest("1346789")));
+  EXPECT_THAT(tracker.status(), IsOk());
+}
+
+TEST(BmffHasher, ErrorMixedInclusionsAndExclusions) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kExclusion, .offset = 1, .length = 1},
+                             {.op = kInclusion, .offset = 3, .length = 2}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, ErrorInsertingOutOfInput) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create(
+      "sha256", {{.op = kInsertion, .offset = 10}}, tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, ErrorExclusionOverlapping) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kExclusion,
+                              .offset = 1,
+                              .length = 0},  // exclude the rest of file.
+                             {.op = kExclusion, .offset = 2}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, ErrorExclusionOverlappingNonEnd) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kExclusion, .offset = 1, .length = 3},
+                             {.op = kExclusion, .offset = 2, .length = 2}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, ErrorExclusionOutOfOrder) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kExclusion, .offset = 5, .length = 2},
+                             {.op = kExclusion, .offset = 2, .length = 2}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, ErrorInclusionOverlapping) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kInclusion, .offset = 1, .length = 3},
+                             {.op = kInclusion, .offset = 2, .length = 2}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, ErrorInclusionOutOfOrder) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher =
+      BindingHasher::Create("sha256",
+                            {{.op = kInclusion, .offset = 5, .length = 2},
+                             {.op = kInclusion, .offset = 2, .length = 2}},
+                            tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, DigestUnorderedExclusionsCPUExhaustionLoop) {
+  std::string input(1000, 'a');
+  std::vector<BindingHasher::Chunk> chunks;
+  for (int i = 0; i < 1000; ++i) {
+    chunks.push_back(
+        {.op = kExclusion, .offset = (i % 2 == 0) ? 500u : 10u, .length = 5});
+  }
+
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create("sha256", chunks, tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, ErrorChunkMalformed) {
+  auto input = "0123456789";
+  riegeli::StringReader<> reader(input);
+
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create(
+      "sha256", {{.op = kExclusion, .offset = 3, .length = -1}}, tracker);
+  ASSERT_THAT(hasher, NotNull());
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("malformed")));
+}
+
+TEST(BmffHasher, ErrorExclusionMismatch) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create(
+      "sha256", {{.op = kExclusion, .offset = 3, .length = 10}}, tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("mismatch")));
+}
+
+TEST(BmffHasher, ErrorInclusionMismatch) {
+  auto input = "0123456789";
+
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create(
+      "sha256", {{.op = kInclusion, .offset = 3, .length = 10}}, tracker);
+  ASSERT_THAT(hasher, NotNull());
+  riegeli::StringReader<> reader(input);
+  auto result = hasher->Digest(reader, tracker);
+  ASSERT_FALSE(result.has_value());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("mismatch")));
+}
+
+TEST(BmffHasher, ErrorAlgorithmUnsupported) {
+  StatusBindingHasherTracker tracker;
+  auto hasher = BindingHasher::Create("abcd", {}, tracker);
+  EXPECT_THAT(hasher, IsNull());
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument,
+                                         HasSubstr("unsupported")));
+}
+
+TEST(BmffHasher, ErrorHasherFailed) {
+  {
+    auto input = "0123456789";
+
+    StatusBindingHasherTracker tracker;
+    BindingHasher hasher(std::make_unique<ErrorHasher>(), /*chunks=*/{});
+    riegeli::StringReader<> reader(input);
+    auto result = hasher.Digest(reader, tracker);
+    ASSERT_FALSE(result.has_value());
+    EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument));
+  }
+  {
+    auto input = "0123456789";
+
+    StatusBindingHasherTracker tracker;
+    BindingHasher hasher(std::make_unique<ErrorHasher>(),
+                         {{.op = kExclusion, .offset = 1, .length = 1}});
+    riegeli::StringReader<> reader(input);
+    auto result = hasher.Digest(reader, tracker);
+    ASSERT_FALSE(result.has_value());
+    EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument));
+  }
+  {
+    auto input = "0123456789";
+
+    StatusBindingHasherTracker tracker;
+    BindingHasher hasher(
+        std::make_unique<ErrorHasher>(),
+        {{.op = kInsertion, .offset = 0, .additional_data = "AAA"}});
+    riegeli::StringReader<> reader(input);
+    auto result = hasher.Digest(reader, tracker);
+    ASSERT_FALSE(result.has_value());
+    EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument));
+  }
+  {
+    auto input = "0123456789";
+
+    StatusBindingHasherTracker tracker;
+    BindingHasher hasher(std::make_unique<ErrorHasher>(),
+                         {{.op = kInclusion, .offset = 0}});
+    riegeli::StringReader<> reader(input);
+    auto result = hasher.Digest(reader, tracker);
+    ASSERT_FALSE(result.has_value());
+    EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kInvalidArgument));
+  }
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/bindings/bmff_binding_hasher.cc b/bindings/bmff_binding_hasher.cc
new file mode 100644
index 0000000..96d3147
--- /dev/null
+++ b/bindings/bmff_binding_hasher.cc
@@ -0,0 +1,98 @@
+// 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/bmff_binding_hasher.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "bindings/binding_hasher.h"
+#include "formats/bmff/box_header.h"
+#include "formats/bmff/box_matcher.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/byte_writers.h"
+
+namespace credentio {
+
+namespace {
+
+std::string Uint64ToBytes(uint64_t value) {
+  std::vector<uint8_t> bytes;
+  bytes.reserve(sizeof(uint64_t));
+  WriteUint64NetworkOrder(value, &bytes);
+  return std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size());
+}
+
+absl_nullable std::unique_ptr<BindingHasher> BuildBmffV2Hasher(
+    const BmffBasedHashAssertion& bmff_hash, riegeli::Reader& file_contents,
+    BindingHasherTracker& tracker) {
+  // Build box matchers from the assertion.
+  std::vector<BMFFBoxMatcher> matchers;
+  matchers.reserve(bmff_hash.exclusions_size());
+  for (const auto& bmff_range : bmff_hash.exclusions()) {
+    matchers.emplace_back(bmff_range, &file_contents);
+  }
+
+  // Match boxes and build exclusion and insertion chunks for the hasher.
+  std::vector<BindingHasher::Chunk> chunks;
+  absl::Status iterator_status = IterateOverBmffBoxes(
+      file_contents,
+      [&chunks, &matchers](const BmffBoxHeader& box) -> absl::StatusOr<bool> {
+        std::vector<BMFFBoxMatcher::Range> exclusions;
+        bool is_entire_box_excluded = false;
+        for (const auto& matcher : matchers) {
+          if (matcher.Matches(box)) {
+            exclusions = matcher.MatchedRanges(box);
+            is_entire_box_excluded = matcher.IsMatchingEntireBox();
+            break;
+          }
+        }
+        if (!is_entire_box_excluded && box.IsRootBox()) {
+          chunks.push_back({.op = BindingHasher::Chunk::Op::kInsertion,
+                            .offset = box.start,
+                            .additional_data = Uint64ToBytes(box.start)});
+        }
+        for (const auto& exclusion : exclusions) {
+          chunks.push_back({.op = BindingHasher::Chunk::Op::kExclusion,
+                            .offset = exclusion.offset,
+                            .length = exclusion.length});
+        }
+        return true;
+      });
+  if (!iterator_status.ok()) {
+    tracker.RecordGeneralError(iterator_status);
+    return nullptr;
+  }
+  return BindingHasher::Create(bmff_hash.alg(), chunks, tracker);
+}
+}  // namespace
+
+std::optional<std::string> BmffBindingHasher::Digest(
+    riegeli::Reader& input) const {
+  auto hasher = BuildBmffV2Hasher(assertion_, input, tracker_);
+  if (hasher == nullptr) {
+    return std::nullopt;
+  }
+  return hasher->Digest(input, tracker_);
+}
+
+}  // namespace credentio
diff --git a/bindings/bmff_binding_hasher.h b/bindings/bmff_binding_hasher.h
new file mode 100644
index 0000000..adcf746
--- /dev/null
+++ b/bindings/bmff_binding_hasher.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_BINDINGS_BMFF_BINDING_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_BINDING_HASHER_H_
+
+#include <optional>
+#include <string>
+
+#include "absl/log/die_if_null.h"
+#include "bindings/binding_hasher.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+// A hasher to digest a BMFF file according to a C2PA hash assertion.
+class BmffBindingHasher {
+ public:
+  BmffBindingHasher(const BmffBasedHashAssertion* assertion,
+                    BindingHasherTracker* tracker)
+      : assertion_(*ABSL_DIE_IF_NULL(assertion)),
+        tracker_(*ABSL_DIE_IF_NULL(tracker)) {}
+  // Digests a BMFF file according to an underlying BmffBasedHashAssertion.
+  std::optional<std::string> Digest(riegeli::Reader& input) const;
+
+ private:
+  const BmffBasedHashAssertion& assertion_;
+  BindingHasherTracker& tracker_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_BINDING_HASHER_H_
diff --git a/bindings/bmff_binding_hasher_test.cc b/bindings/bmff_binding_hasher_test.cc
new file mode 100644
index 0000000..200c9fa
--- /dev/null
+++ b/bindings/bmff_binding_hasher_test.cc
@@ -0,0 +1,79 @@
+// 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/bmff_binding_hasher.h"
+
+#include <optional>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "bindings/binding_hasher.h"
+#include "formats/bmff/test_utils.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+using ::testing::Ne;
+
+TEST(BmffBindingHasherTest, DigestInvalidInput) {
+  riegeli::StringReader<> input("some fake content");
+  BmffBasedHashAssertion assertion;
+  StatusBindingHasherTracker tracker;
+  BmffBindingHasher hasher(&assertion, &tracker);
+  EXPECT_THAT(hasher.Digest(input), Eq(std::nullopt));
+  EXPECT_THAT(tracker.status(), StatusIs(absl::StatusCode::kDataLoss));
+}
+
+TEST(BmffBindingHasherTest, XPathBypassWithBracketBoxType) {
+  std::string contents1 = credentio_testing::Box("[moo", "payload1_data_here");
+  std::string contents2 = credentio_testing::Box("[moo", "payload2_data_here");
+  riegeli::StringReader<> input1(contents1);
+  riegeli::StringReader<> input2(contents2);
+
+  BmffBasedHashAssertion assertion;
+  assertion.set_alg("sha256");
+  auto* exclusion = assertion.add_exclusions();
+  exclusion->set_xpath("/moov");
+
+  StatusBindingHasherTracker tracker1;
+  BmffBindingHasher hasher1(&assertion, &tracker1);
+  auto digest1 = hasher1.Digest(input1);
+  ASSERT_TRUE(digest1.has_value());
+  EXPECT_THAT(tracker1.status(), IsOk());
+
+  StatusBindingHasherTracker tracker2;
+  BmffBindingHasher hasher2(&assertion, &tracker2);
+  auto digest2 = hasher2.Digest(input2);
+  ASSERT_TRUE(digest2.has_value());
+  EXPECT_THAT(tracker2.status(), IsOk());
+
+  // Without the fix, "[moo" was treated as matching "/moov", causing the entire
+  // box payload to be excluded and making digest1 == digest2.
+  // With the fix, the box is not excluded, so different payloads produce
+  // different digests.
+  EXPECT_THAT(digest1, Ne(digest2));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/bindings/bmff_exclusion_checker.cc b/bindings/bmff_exclusion_checker.cc
new file mode 100644
index 0000000..72951fb
--- /dev/null
+++ b/bindings/bmff_exclusion_checker.cc
@@ -0,0 +1,111 @@
+// 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/bmff_exclusion_checker.h"
+
+#include <algorithm>
+
+#include "absl/strings/match.h"
+#include "absl/strings/string_view.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+
+namespace credentio {
+
+namespace {
+// Required exclusions as defined in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_exclusion_list_requirements
+bool IsRootBox(absl::string_view xpath, absl::string_view box_type) {
+  if (xpath.size() < box_type.size() + 1) {
+    return false;
+  }
+  if (xpath[0] != '/') {
+    return false;
+  }
+  if (xpath.substr(1, box_type.size()) != box_type) {
+    return false;
+  }
+  if (xpath.size() == box_type.size() + 1) {
+    return true;
+  }
+  if (xpath[box_type.size() + 1] == '[' && xpath.back() == ']') {
+    absl::string_view index_str =
+        xpath.substr(box_type.size() + 2, xpath.size() - box_type.size() - 3);
+    if (index_str.empty()) {
+      return false;
+    }
+    for (char c : index_str) {
+      if (c < '0' || c > '9') {
+        return false;
+      }
+    }
+    return true;
+  }
+  return false;
+}
+
+bool IsRequiredExclusion(const BmffRange& exclusion, bool has_merkle_maps) {
+  if (IsRootBox(exclusion.xpath(), "ftyp") && exclusion.subsets().empty()) {
+    return true;
+  }
+  if (IsRootBox(exclusion.xpath(), "mfra") && exclusion.subsets().empty()) {
+    return true;
+  }
+
+  // These exclusion xpaths must match exactly as the additional constraints
+  // cannot be applied if the xpath selects a lower level node
+  if (IsRootBox(exclusion.xpath(), "uuid") && exclusion.subsets().empty() &&
+      exclusion.data_size() == 1 && exclusion.data(0).offset() == 8 &&
+      exclusion.data(0).value() ==
+          "\330\376\303\326\033\016H<\222\227X(\207~\304\201") {
+    return true;
+  }
+  if (has_merkle_maps && exclusion.xpath() == "/mdat" &&
+      exclusion.subsets_size() == 1 && exclusion.subsets(0).offset() == 16 &&
+      exclusion.subsets(0).length() == 0) {
+    return true;
+  }
+  return false;
+}
+
+// Additional allowed exclusions as defined in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validating_a_bmff_hash
+bool IsAllowedExclusion(const BmffRange& exclusion) {
+  if (absl::StartsWith(exclusion.xpath(), "/free")) {
+    return true;
+  }
+  if (absl::StartsWith(exclusion.xpath(), "/skip")) {
+    return true;
+  }
+  return false;
+}
+}  // namespace
+
+bool HasAdditionalExclusions(const BmffBasedHashAssertion& assertion) {
+  if (assertion.exclusions().empty()) {
+    // No exclusions are specified, so there can't be any additional exclusions.
+    return false;
+  }
+
+  bool has_merkle_maps = assertion.has_hash() && assertion.merkles_size() > 0;
+
+  return std::any_of(
+      assertion.exclusions().begin(), assertion.exclusions().end(),
+      [&has_merkle_maps](const BmffRange& exclusion) {
+        return !IsRequiredExclusion(exclusion, has_merkle_maps) &&
+               !IsAllowedExclusion(exclusion);
+      });
+}
+
+}  // namespace credentio
diff --git a/bindings/bmff_exclusion_checker.h b/bindings/bmff_exclusion_checker.h
new file mode 100644
index 0000000..1aa3a11
--- /dev/null
+++ b/bindings/bmff_exclusion_checker.h
@@ -0,0 +1,31 @@
+// 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_BINDINGS_BMFF_EXCLUSION_CHECKER_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_EXCLUSION_CHECKER_H_
+
+#include "proto/bmff_based_hash_assertion.pb.h"
+
+namespace credentio {
+
+// Returns true if the given assertion contains any additional exclusions
+// beyond the required or allowed exclusions.
+// See:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_exclusion_list_requirements
+bool HasAdditionalExclusions(const BmffBasedHashAssertion& assertion);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_EXCLUSION_CHECKER_H_
diff --git a/bindings/bmff_hash_hard_binding_validator.cc b/bindings/bmff_hash_hard_binding_validator.cc
new file mode 100644
index 0000000..e318885
--- /dev/null
+++ b/bindings/bmff_hash_hard_binding_validator.cc
@@ -0,0 +1,142 @@
+// 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/bmff_hash_hard_binding_validator.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "bindings/binding_hasher.h"
+#include "bindings/bmff_binding_hasher.h"
+#include "bindings/bmff_exclusion_checker.h"
+#include "bindings/merkle_validator.h"
+#include "constants/status_codes.h"
+#include "formats/asset_byte_info.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/dual_status_tracker.h"
+#include "utils/status_tracker.h"
+#include "validator/result.h"
+#include "validator/validation_result_internal.h"
+
+namespace credentio {
+
+namespace {
+class BmffHashBindingHasherTracker : public BindingHasherTracker {
+ public:
+  explicit BmffHashBindingHasherTracker(StatusTracker* tracker,
+                                        absl::string_view hard_binding_uri)
+      : tracker_(*ABSL_DIE_IF_NULL(tracker)),
+        hard_binding_uri_(hard_binding_uri) {}
+  void RecordMismatch() override {
+    tracker_.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordMalformed() override {
+    tracker_.RecordFailure(FailureStatusCode::kAssertionBmffHashMalformed,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordAlgorithmUnsupported() override {
+    tracker_.RecordFailure(FailureStatusCode::kAlgorithmUnsupported,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordGeneralError(absl::Status error) override {
+    tracker_.RecordFailure(
+        FailureStatusCode::kGeneralError,
+        {.url = hard_binding_uri_, .explanation = error.message()});
+  }
+
+ private:
+  StatusTracker& tracker_;
+  absl::string_view hard_binding_uri_;
+};
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ValidationResultProto>>
+BmffHashHardBindingValidator::Validate(
+    riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+    std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+    const {
+  const Assertion* absl_nullable assertion =
+      GetAssertion(partial_validation_result.get(),
+                   partial_validation_result->hard_binding_uri());
+  if (assertion == nullptr) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "missing assertion: ", partial_validation_result->hard_binding_uri()));
+  }
+  if (!assertion->has_bmff_based_hash()) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("assertion is not a bmff hash assertion: ",
+                     partial_validation_result->hard_binding_uri()));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto tracker,
+                        DualStatusTracker::FromPartialValidationResult(
+                            partial_validation_result.get()));
+
+  Validate(contents, assertion->bmff_based_hash(),
+           partial_validation_result->hard_binding_uri(), *tracker);
+
+  return MakeFullValidationResult(std::move(partial_validation_result));
+}
+
+void BmffHashHardBindingValidator::Validate(
+    riegeli::Reader& contents, const BmffBasedHashAssertion& assertion,
+    absl::string_view hard_binding_uri, StatusTracker& tracker) const {
+  if (!contents.Seek(0) || contents.pos() != 0) {
+    tracker.RecordFailure(FailureStatusCode::kGoogleInternalError,
+                          {.url = hard_binding_uri});
+    return;
+  }
+
+  if (HasAdditionalExclusions(assertion)) {
+    tracker.RecordInformational(
+        InformationalStatusCode::kAssertionBmffHashAdditionalExclusionsPresent,
+        {.url = hard_binding_uri});
+  }
+
+  BmffHashBindingHasherTracker hasher_tracker(&tracker, hard_binding_uri);
+  BmffBindingHasher hasher(&assertion, &hasher_tracker);
+  auto digest = hasher.Digest(contents);
+  if (digest.value_or("") != assertion.hash()) {
+    tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                          {.url = hard_binding_uri});
+    return;
+  }
+
+  if (assertion.merkles_size() > 0) {
+    MerkleValidator merkle_validator(contents, assertion, hard_binding_uri);
+
+    if (!merkle_validator.Validate(tracker).ok()) {
+      // Failure Codes were already recorded by ValidateMerkleMaps.
+      return;
+    }
+  }
+
+  tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch,
+                        {.url = hard_binding_uri});
+}
+
+}  // namespace credentio
diff --git a/bindings/bmff_hash_hard_binding_validator.h b/bindings/bmff_hash_hard_binding_validator.h
new file mode 100644
index 0000000..4ee0788
--- /dev/null
+++ b/bindings/bmff_hash_hard_binding_validator.h
@@ -0,0 +1,47 @@
+// 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_BINDINGS_BMFF_HASH_HARD_BINDING_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_HASH_HARD_BINDING_VALIDATOR_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "bindings/hard_binding_validator.h"
+#include "formats/asset_byte_info.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/status_tracker.h"
+
+namespace credentio {
+
+class BmffHashHardBindingValidator : public HardBindingValidator {
+ public:
+  absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate(
+      riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+      std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+      const override;
+
+  void Validate(riegeli::Reader& contents,
+                const BmffBasedHashAssertion& assertion,
+                absl::string_view hard_binding_uri,
+                StatusTracker& tracker) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_BMFF_HASH_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/boxes_hash_hard_binding_validator.cc b/bindings/boxes_hash_hard_binding_validator.cc
new file mode 100644
index 0000000..22359ea
--- /dev/null
+++ b/bindings/boxes_hash_hard_binding_validator.cc
@@ -0,0 +1,266 @@
+// 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/boxes_hash_hard_binding_validator.h"
+
+#include <cstdint>
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "bindings/binding_hasher.h"
+#include "bindings/boxes_hash_validator.h"
+#include "constants/status_codes.h"
+#include "formats/asset_box.h"
+#include "formats/asset_byte_info.h"
+#include "formats/byte_range.h"
+#include "proto/assertion.pb.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/dual_status_tracker.h"
+#include "utils/status_tracker.h"
+#include "validator/result.h"
+#include "validator/validation_result_internal.h"
+
+namespace credentio {
+
+namespace {
+
+class BoxesHashBindingHasherTracker : public BindingHasherTracker {
+ public:
+  explicit BoxesHashBindingHasherTracker(StatusTracker* tracker,
+                                         absl::string_view hard_binding_uri)
+      : tracker_(*ABSL_DIE_IF_NULL(tracker)),
+        hard_binding_uri_(hard_binding_uri) {}
+  void RecordMismatch() override {
+    tracker_.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordMalformed() override {
+    tracker_.RecordFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordAlgorithmUnsupported() override {
+    tracker_.RecordFailure(FailureStatusCode::kAlgorithmUnsupported,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordGeneralError(absl::Status error) override {
+    tracker_.RecordFailure(
+        FailureStatusCode::kGeneralError,
+        {.url = hard_binding_uri_, .explanation = error.message()});
+  }
+
+ private:
+  StatusTracker& tracker_;
+  absl::string_view hard_binding_uri_;
+};
+
+absl::Status AddInclusions(const AssetBox& asset_box,
+                           absl::Span<const BoxExclusion> exclusions,
+                           absl::string_view hard_binding_uri,
+                           StatusTracker& tracker,
+                           std::vector<BindingHasher::Chunk>& inclusions) {
+  uint64_t last_handled_offset = asset_box.byte_range.offset;
+  for (const auto& exclusion : exclusions) {
+    uint64_t exclusion_asset_offset =
+        asset_box.byte_range.offset + exclusion.start();
+
+    if (exclusion.start() >= asset_box.byte_range.length ||
+        exclusion.length() >
+            (asset_box.byte_range.length - exclusion.start())) {
+      // Exclusion starts after the box or extends beyond the end of the box.
+      tracker.RecordFailure(
+          FailureStatusCode::kAssertionBoxesHashMalformed,
+          {.url = hard_binding_uri,
+           .explanation = "Exclusion starts after the box or extends beyond "
+                          "the end of the box"});
+      return absl::InvalidArgumentError(
+          "Exclusion starts after the box or extends beyond the end of the "
+          "box");
+    }
+
+    if (last_handled_offset < exclusion_asset_offset) {
+      inclusions.push_back({
+          .op = BindingHasher::Chunk::Op::kInclusion,
+          .offset = last_handled_offset,
+          .length = static_cast<int64_t>(exclusion_asset_offset) -
+                    static_cast<int64_t>(last_handled_offset),
+      });
+    }
+    last_handled_offset = exclusion_asset_offset + exclusion.length();
+  }
+  if (last_handled_offset <
+      (asset_box.byte_range.offset + asset_box.byte_range.length)) {
+    inclusions.push_back({
+        .op = BindingHasher::Chunk::Op::kInclusion,
+        .offset = last_handled_offset,
+        .length = static_cast<int64_t>(asset_box.byte_range.offset +
+                                       asset_box.byte_range.length) -
+                  static_cast<int64_t>(last_handled_offset),
+    });
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ValidationResultProto>>
+BoxesHashHardBindingValidator::Validate(
+    riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+    std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+    const {
+  const Assertion* absl_nullable assertion =
+      GetAssertion(partial_validation_result.get(),
+                   partial_validation_result->hard_binding_uri());
+  if (assertion == nullptr) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "missing assertion: ", partial_validation_result->hard_binding_uri()));
+  }
+  if (!assertion->has_boxes_hash()) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("assertion is not a boxes hash assertion: ",
+                     partial_validation_result->hard_binding_uri()));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto tracker,
+                        DualStatusTracker::FromPartialValidationResult(
+                            partial_validation_result.get()));
+
+  Validate(contents, asset_byte_info.boxes, assertion->boxes_hash(),
+           partial_validation_result->hard_binding_uri(), *tracker);
+
+  return MakeFullValidationResult(std::move(partial_validation_result));
+}
+
+void BoxesHashHardBindingValidator::Validate(
+    riegeli::Reader& contents, std::vector<AssetBox> contents_boxes,
+    const BoxesHashAssertion& assertion, absl::string_view hard_binding_uri,
+    StatusTracker& tracker) const {
+  if (!BoxesHashValidator(assertion, hard_binding_uri,
+                          /*claims_algo=*/"", tracker)
+           .Validate()
+           .ok()) {
+    return;
+  }
+
+  // Needed to determine "unknown box" vs "out of order box"
+  absl::flat_hash_map<absl::string_view, uint64_t> box_counts;
+  for (const auto& box : contents_boxes) {
+    box_counts[box.identifier] += 1;
+  }
+
+  int asset_box_index = 0;
+  for (const auto& assertion_box_map : assertion.boxes()) {
+    auto algorithm(assertion_box_map.alg());
+    if (algorithm.empty()) {
+      algorithm = assertion.alg();
+    }
+
+    std::vector<BindingHasher::Chunk> inclusions;
+    for (int box_name_idx = 0; box_name_idx < assertion_box_map.names_size();
+         ++box_name_idx) {
+      const absl::string_view assertion_box_name =
+          assertion_box_map.names(box_name_idx);
+
+      if (asset_box_index >= contents_boxes.size()) {
+        // Additional boxes in the assertion that are not in the asset.
+        tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch,
+                              {.url = hard_binding_uri});
+        return;
+      }
+
+      const auto& current_asset_box = contents_boxes[asset_box_index];
+      if (current_asset_box.identifier != assertion_box_name) {
+        tracker.RecordFailure(
+            box_counts[assertion_box_name] > 0
+                ? FailureStatusCode::kAssertionBoxesHashMismatch
+                : FailureStatusCode::kAssertionBoxesHashUnknownBox,
+            {.url = hard_binding_uri});
+        return;
+      }
+      --box_counts[assertion_box_name];
+
+      if (assertion_box_name != "C2PA") {
+        std::vector<BoxExclusion> exclusions;
+        if (!assertion_box_map.excluded()) {
+          for (const auto& exclusion : assertion_box_map.exclusions()) {
+            if (exclusion.has_box_index() &&
+                exclusion.box_index() > box_name_idx) {
+              // Exclusion is for a box further down the line.
+              break;
+            }
+            if (!exclusion.has_box_index() ||
+                exclusion.box_index() == box_name_idx) {
+              exclusions.push_back(exclusion);
+            }
+          }
+        }
+
+        if (!AddInclusions(current_asset_box, exclusions, hard_binding_uri,
+                           tracker, inclusions)
+                 .ok()) {
+          return;
+        }
+      }
+      ++asset_box_index;
+    }
+    BoxesHashBindingHasherTracker boxes_hash_tracker(&tracker,
+                                                     hard_binding_uri);
+    if (!inclusions.empty()) {
+      auto hasher = BindingHasher::Create(algorithm, std::move(inclusions),
+                                          boxes_hash_tracker);
+      if (hasher == nullptr) {
+        return;
+      }
+      auto digest = hasher->Digest(contents, boxes_hash_tracker);
+      if (!digest.has_value()) {
+        tracker.RecordFailure(
+            FailureStatusCode::kGoogleInternalError,
+            {.url = hard_binding_uri, .explanation = "Digest failed"});
+        return;
+      }
+      // If the box_set is excluded, we don't care if the hash matches, just
+      // that it can be hashed.
+      bool required =
+          !(assertion_box_map.has_excluded() && assertion_box_map.excluded());
+      if (required && *digest != assertion_box_map.hash()) {
+        tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch,
+                              {.url = hard_binding_uri});
+        return;
+      }
+    }
+  }
+  if (asset_box_index < contents_boxes.size()) {
+    // Additional boxes in the asset that are not in the assertion.
+    tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashUnknownBox,
+                          {.url = hard_binding_uri});
+    return;
+  }
+  tracker.RecordSuccess(SuccessStatusCode::kAssertionBoxesHashMatch,
+                        {.url = hard_binding_uri});
+}
+
+}  // namespace credentio
diff --git a/bindings/boxes_hash_hard_binding_validator.h b/bindings/boxes_hash_hard_binding_validator.h
new file mode 100644
index 0000000..ca19786
--- /dev/null
+++ b/bindings/boxes_hash_hard_binding_validator.h
@@ -0,0 +1,48 @@
+// 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_BINDINGS_BOXES_HASH_HARD_BINDING_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_HARD_BINDING_VALIDATOR_H_
+
+#include <memory>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "bindings/hard_binding_validator.h"
+#include "formats/asset_byte_info.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/status_tracker.h"
+
+namespace credentio {
+
+class BoxesHashHardBindingValidator : public HardBindingValidator {
+ public:
+  absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate(
+      riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+      std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+      const override;
+
+  void Validate(riegeli::Reader& contents, std::vector<AssetBox> contents_boxes,
+                const BoxesHashAssertion& assertion,
+                absl::string_view hard_binding_uri,
+                StatusTracker& tracker) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/boxes_hash_validator.cc b/bindings/boxes_hash_validator.cc
new file mode 100644
index 0000000..80c4bd6
--- /dev/null
+++ b/bindings/boxes_hash_validator.cc
@@ -0,0 +1,190 @@
+// 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/boxes_hash_validator.h"
+
+#include <cstdint>
+#include <limits>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "constants/status_codes.h"
+#include "crypto/algorithms.h"
+#include "proto/boxes_hash_assertion.pb.h"
+
+namespace credentio {
+namespace {
+
+constexpr absl::string_view kSingleZeroByte("\0", 1);
+
+absl::string_view DetermineAlgorithm(
+    absl::Span<const absl::string_view> algos) {
+  absl::string_view algo_to_use = "";
+  for (const auto& alg : algos) {
+    if (!alg.empty()) {
+      algo_to_use = alg;
+      break;
+    }
+  }
+  return algo_to_use;
+}
+
+}  // namespace
+
+absl::Status BoxesHashValidator::Validate() {
+  if (assertion_.boxes().empty()) {
+    return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                      "box-map.boxes is empty");
+  }
+
+  for (const auto& box_hash_map : assertion_.boxes()) {
+    ABSL_RETURN_IF_ERROR(ValidateBoxHashMap(box_hash_map));
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status BoxesHashValidator::LogFailure(FailureStatusCode failure_code,
+                                            absl::string_view explanation) {
+  tracker_.RecordFailure(
+      failure_code, {.url = hard_binding_uri_, .explanation = explanation});
+  return absl::InvalidArgumentError(explanation);
+}
+
+void BoxesHashValidator::LogAdditionalExclusionsPresent() {
+  if (!found_additional_exclusions_) {
+    found_additional_exclusions_ = true;
+    tracker_.RecordInformational(
+        InformationalStatusCode::kAssertionBoxesHashAdditionalExclusionsPresent,
+        {.url = hard_binding_uri_});
+  }
+}
+
+absl::Status BoxesHashValidator::ValidateBoxHashMap(
+    const BoxHash& box_hash_map) {
+  if (box_hash_map.names().empty()) {
+    return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                      "box-map.names is empty");
+  }
+
+  if (box_hash_map.hash().empty()) {
+    return LogFailure(FailureStatusCode::kAssertionBoxesHashMismatch,
+                      "box-map.hash is empty");
+  }
+
+  absl::string_view algo =
+      DetermineAlgorithm({box_hash_map.alg(), assertion_.alg(), claims_algo_});
+  if (!ParseHashAlgorithm(algo).ok()) {
+    return LogFailure(FailureStatusCode::kAlgorithmUnsupported,
+                      absl::StrCat("Algorithm not supported: ", algo));
+  }
+
+  bool contains_c2pa_box = false;
+  for (const auto& name : box_hash_map.names()) {
+    if (name == "C2PA") {
+      contains_c2pa_box = true;
+      if (box_hash_map.names_size() != 1) {
+        return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                          "C2PA box must be the only name in the names array");
+      }
+      if (box_hash_map.hash() != kSingleZeroByte) {
+        // Is this the right error code? It's not really a mismatch.
+        return LogFailure(FailureStatusCode::kAssertionBoxesHashMismatch,
+                          "C2PA box hash is not a single 0 byte");
+      }
+    }
+  }
+
+  if (box_hash_map.excluded()) {
+    return ValidateExclusion(box_hash_map, contains_c2pa_box);
+  } else if (!box_hash_map.exclusions().empty()) {
+    return ValidateExclusionRanges(box_hash_map, contains_c2pa_box);
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status BoxesHashValidator::ValidateExclusion(const BoxHash& box_hash_map,
+                                                   bool contains_c2pa_box) {
+  if (!contains_c2pa_box) {
+    LogAdditionalExclusionsPresent();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status BoxesHashValidator::ValidateExclusionRanges(
+    const BoxHash& box_hash_map, bool contains_c2pa_box) {
+  int64_t last_box_index = -1;
+  int64_t last_start = -1;
+  int64_t last_end_offset = -1;
+
+  for (const auto& exclusion : box_hash_map.exclusions()) {
+    if (exclusion.start() < 0 || exclusion.length() < 0) {
+      return LogFailure(
+          FailureStatusCode::kAssertionBoxesHashMalformed,
+          "box-map.start and box-map.length must be non-negative");
+    }
+    if (std::numeric_limits<int64_t>::max() - exclusion.start() <
+        exclusion.length()) {
+      return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                        "box-map.start + box-map.length overflows");
+    }
+
+    if (!exclusion.has_box_index() && box_hash_map.names_size() > 1) {
+      return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                        "box-map.boxIndex is required when there is more "
+                        "than one box");
+    } else if (exclusion.has_box_index() &&
+               (exclusion.box_index() < 0 ||
+                exclusion.box_index() >= box_hash_map.names_size())) {
+      return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                        "box-map.boxIndex is out of range");
+    }
+
+    int64_t new_box_index =
+        exclusion.has_box_index() ? exclusion.box_index() : 0;
+    if (last_box_index == -1 || last_box_index < new_box_index) {
+      // First exclusion of this box, so just set the last box index.
+      last_box_index = new_box_index;
+      last_start = exclusion.start();
+      last_end_offset = last_start + exclusion.length();
+      continue;
+    } else if (new_box_index < last_box_index) {
+      // Next box index should be greater or equal to the last one.
+      return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                        "box-map.boxIndex is not in order");
+    } else if (new_box_index == last_box_index) {
+      if (last_start >= exclusion.start() ||
+          last_end_offset > exclusion.start()) {
+        return LogFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                          "box-map.start is overlapping with the last "
+                          "box-map.start");
+      }
+      last_start = exclusion.start();
+      last_end_offset = last_start + exclusion.length();
+    }
+  }
+
+  if (!contains_c2pa_box) {
+    LogAdditionalExclusionsPresent();
+  }
+
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/bindings/boxes_hash_validator.h b/bindings/boxes_hash_validator.h
new file mode 100644
index 0000000..b4a2e62
--- /dev/null
+++ b/bindings/boxes_hash_validator.h
@@ -0,0 +1,77 @@
+// 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_BINDINGS_BOXES_HASH_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_VALIDATOR_H_
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "utils/status_tracker.h"
+
+namespace credentio {
+
+// Validates the structure of a BoxesHashAssertion. Only things such as the
+// presence of required fields, and ranges are validated. The hash values are
+// not validated.
+class BoxesHashValidator {
+ public:
+  explicit BoxesHashValidator(const BoxesHashAssertion& assertion,
+                              absl::string_view hard_binding_uri,
+                              absl::string_view claims_algo,
+                              StatusTracker& tracker)
+      : assertion_(assertion),
+        hard_binding_uri_(hard_binding_uri),
+        claims_algo_(claims_algo),
+        tracker_(tracker) {}
+
+  // Validates the structure of the BoxesHashAssertion. Returns an
+  // InvalidArgumentError if the structure is invalid. This method should be
+  // called before attempting to validate the hashes contained in this
+  // assertion.
+  absl::Status Validate();
+
+ private:
+  // Helper method to log a failure code and explanation to the tracker, and
+  // return an InvalidArgumentError.
+  absl::Status LogFailure(FailureStatusCode failure_code,
+                          absl::string_view explanation);
+
+  // Helper method to log an informational code to the tracker if additional
+  // exclusions are present in the assertion.
+  void LogAdditionalExclusionsPresent();
+
+  // Validates a single box hash map in the assertion.
+  absl::Status ValidateBoxHashMap(const BoxHash& box_hash_map);
+
+  // Validates an excluded box hash map in the assertion.
+  absl::Status ValidateExclusion(const BoxHash& box_hash_map,
+                                 bool contains_c2pa_box);
+
+  // Validates a box hash map with exclusion ranges in the assertion.
+  absl::Status ValidateExclusionRanges(const BoxHash& box_hash_map,
+                                       bool contains_c2pa_box);
+
+  const BoxesHashAssertion& assertion_;
+  absl::string_view hard_binding_uri_;
+  absl::string_view claims_algo_;
+  StatusTracker& tracker_;
+  bool found_additional_exclusions_ = false;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_BOXES_HASH_VALIDATOR_H_
diff --git a/bindings/boxes_hash_validator_test.cc b/bindings/boxes_hash_validator_test.cc
new file mode 100644
index 0000000..5245668
--- /dev/null
+++ b/bindings/boxes_hash_validator_test.cc
@@ -0,0 +1,423 @@
+// 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/boxes_hash_validator.h"
+
+#include <string>
+
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/util/message_differencer.h"
+#include "gtest/gtest.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/validation_status.pb.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_status_tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::ParseTextProtoOrDie;
+
+struct ExpectedValues {
+  absl::Status status;
+  ValidationStatusSet validation;
+};
+
+// Matcher for ValidationStatusSet that ignores the `url` field.
+MATCHER_P(EqualsValidationStatusSetIgnoringUrl, expected, "") {
+  google::protobuf::util::MessageDifferencer differencer;
+  differencer.IgnoreField(
+      ValidationStatus::descriptor()->FindFieldByName("url"));
+  return differencer.Compare(arg, expected);
+}
+
+struct BoxesHashValidatorTestParams {
+  std::string name;
+  BoxesHashAssertion assertion;
+  ExpectedValues expected;
+};
+
+ExpectedValues CreateStatusSet(bool has_additional_exclusions = false) {
+  ExpectedValues expected;
+
+  expected.status = absl::OkStatus();
+
+  if (has_additional_exclusions) {
+    expected.validation.add_informationals()->set_code(absl::StrFormat(
+        "%v", InformationalStatusCode::
+                  kAssertionBoxesHashAdditionalExclusionsPresent));
+  }
+  return expected;
+}
+
+ExpectedValues CreateStatusSet(FailureStatusCode failure_code,
+                               absl::string_view explanation,
+                               bool has_additional_exclusions = false) {
+  ExpectedValues expected;
+
+  ValidationStatus* failure = expected.validation.add_failures();
+  failure->set_code(absl::StrFormat("%v", failure_code));
+  failure->set_explanation(explanation);
+
+  expected.status = absl::InvalidArgumentError(explanation);
+
+  if (has_additional_exclusions) {
+    expected.validation.add_informationals()->set_code(absl::StrFormat(
+        "%v", InformationalStatusCode::
+                  kAssertionBoxesHashAdditionalExclusionsPresent));
+  }
+  return expected;
+}
+
+using BoxesHashValidatorTest =
+    testing::TestWithParam<BoxesHashValidatorTestParams>;
+
+TEST_P(BoxesHashValidatorTest, Validate) {
+  TestStatusTracker tracker;
+  BoxesHashValidator validator(GetParam().assertion, "test_uri", "", tracker);
+  EXPECT_EQ(validator.Validate(), GetParam().expected.status);
+  EXPECT_THAT(tracker.GetStatusSet(), EqualsValidationStatusSetIgnoringUrl(
+                                          GetParam().expected.validation));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    BoxesHashValidatorTest, BoxesHashValidatorTest,
+    testing::ValuesIn<BoxesHashValidatorTestParams>({
+        {
+            .name = "empty_assertion",
+            .assertion = BoxesHashAssertion(),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed,
+                                "box-map.boxes is empty"),
+        },
+        {
+            .name = "empty_names",
+            .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+              boxes { hash: "hash" }
+            )pb"),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed,
+                                "box-map.names is empty"),
+        },
+        {
+            .name = "empty_hash",
+            .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+              boxes { names: "box1" }
+            )pb"),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMismatch,
+                                "box-map.hash is empty"),
+        },
+        {
+            .name = "no_algo",
+            .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+              boxes { names: "box1" hash: "hash" }
+            )pb"),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported,
+                                "Algorithm not supported: "),
+        },
+        {
+            .name = "bad_algo_box_map",
+            .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+              boxes { names: "box1" hash: "hash" alg: "bad_algo" }
+            )pb"),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported,
+                                "Algorithm not supported: bad_algo"),
+        },
+        {
+            .name = "bad_algo_assertion",
+            .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+              boxes { names: "box1" hash: "hash" }
+              alg: "bad_algo"
+            )pb"),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAlgorithmUnsupported,
+                                "Algorithm not supported: bad_algo"),
+        },
+        {
+            .name = "c2pa_not_only_box",
+            .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+              boxes { names: "box1" names: "C2PA" hash: "hash" }
+              alg: "sha256"
+            )pb"),
+            .expected = CreateStatusSet(
+                FailureStatusCode::kAssertionBoxesHashMalformed,
+                "C2PA box must be the only name in the names array"),
+        },
+        {
+            .name = "c2pa_not_zero_byte_hash",
+            .assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+              boxes { names: "C2PA" hash: "hash" }
+              alg: "sha256"
+            )pb"),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMismatch,
+                                "C2PA box hash is not a single 0 byte"),
+        },
+        {
+            .name = "only_c2pa_excluded",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes { names: "box1" hash: "hash" }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(),
+        },
+        {
+            .name = "box_1_excluded",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes { names: "box1" hash: "hash", excluded: true }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(/*has_additional_exclusions=*/true),
+        },
+        {
+            .name = "single_box_exclusion_ranges_out_of_order",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  hash: "hash",
+                  exclusions { start: 100 length: 100 }
+                  exclusions { start: 10 length: 50 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(
+                FailureStatusCode::kAssertionBoxesHashMalformed,
+                "box-map.start is overlapping with the last box-map.start"),
+        },
+        {
+            .name = "single_box_exclusion_ranges_overlapping",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  hash: "hash",
+                  exclusions { start: 100 length: 100 }
+                  exclusions { start: 150 length: 100 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(
+                FailureStatusCode::kAssertionBoxesHashMalformed,
+                "box-map.start is overlapping with the last box-map.start"),
+        },
+        {
+            .name = "two_boxes_exclusion_ranges_missing_box_index",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  names: "box2"
+                  hash: "hash",
+                  exclusions { start: 100 length: 100 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(
+                FailureStatusCode::kAssertionBoxesHashMalformed,
+                "box-map.boxIndex is required when there is more than one box"),
+        },
+        {
+            .name = "two_boxes_exclusion_ranges_out_of_range",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  names: "box2"
+                  hash: "hash",
+                  exclusions { start: 100 length: 100 box_index: 2 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed,
+                                "box-map.boxIndex is out of range"),
+        },
+        {
+            .name = "two_boxes_exclusion_ranges_negative_box_index",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  names: "box2"
+                  hash: "hash",
+                  exclusions { start: 100 length: 100 box_index: -1 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed,
+                                "box-map.boxIndex is out of range"),
+        },
+        {
+            .name = "two_boxes_exclusion_ranges_box_index_not_in_order",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  names: "box2"
+                  hash: "hash",
+                  exclusions { start: 100 length: 100 box_index: 1 }
+                  exclusions { start: 100 length: 100 box_index: 0 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed,
+                                "box-map.boxIndex is not in order"),
+        },
+        {
+            .name = "two_boxes_exclusion_ranges_box_index_in_order",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  names: "box2"
+                  hash: "hash"
+                  exclusions { start: 100 length: 100 box_index: 0 }
+                  exclusions { start: 100 length: 100 box_index: 1 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(/*has_additional_exclusions=*/true),
+        },
+        {
+            .name = "one_box_exclusion_ranges_valid",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  hash: "hash",
+                  exclusions { start: 10 length: 10 }
+                  exclusions { start: 30 length: 10 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(/*has_additional_exclusions=*/true),
+        },
+        {
+            .name = "negative_exclusion_start",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  hash: "hash",
+                  exclusions { start: -10 length: 10 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(
+                FailureStatusCode::kAssertionBoxesHashMalformed,
+                "box-map.start and box-map.length must be non-negative"),
+        },
+        {
+            .name = "negative_exclusion_length",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  hash: "hash",
+                  exclusions { start: 10 length: -10 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected = CreateStatusSet(
+                FailureStatusCode::kAssertionBoxesHashMalformed,
+                "box-map.start and box-map.length must be non-negative"),
+        },
+        {
+            .name = "exclusion_range_overflow",
+            .assertion = []() -> BoxesHashAssertion {
+              auto assertion = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+                boxes {
+                  names: "box1"
+                  hash: "hash",
+                  exclusions { start: 9223372036854775800 length: 100 }
+                }
+                boxes { names: "C2PA" hash: "" }
+                alg: "sha256"
+              )pb");
+              assertion.mutable_boxes(1)->set_hash(std::string("\0", 1));
+              return assertion;
+            }(),
+            .expected =
+                CreateStatusSet(FailureStatusCode::kAssertionBoxesHashMalformed,
+                                "box-map.start + box-map.length overflows"),
+        },
+    }),
+    [](const testing::TestParamInfo<BoxesHashValidatorTestParams>& info) {
+      return info.param.name;
+    });
+
+}  // namespace
+}  // namespace credentio
diff --git a/bindings/collection_data_hash_hard_binding_validator.cc b/bindings/collection_data_hash_hard_binding_validator.cc
new file mode 100644
index 0000000..2f64d64
--- /dev/null
+++ b/bindings/collection_data_hash_hard_binding_validator.cc
@@ -0,0 +1,291 @@
+// 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/collection_data_hash_hard_binding_validator.h"
+
+#include <cstdint>
+#include <map>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "bindings/binding_hasher.h"
+#include "bindings/input_hasher.h"
+#include "constants/status_codes.h"
+#include "crypto/algorithms.h"
+#include "formats/asset_byte_info.h"
+#include "formats/zip/constants.h"
+#include "formats/zip/reader.h"
+#include "proto/assertion.pb.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/dual_status_tracker.h"
+#include "utils/status_tracker.h"
+#include "validator/result.h"
+#include "validator/validation_result_internal.h"
+
+namespace credentio {
+
+namespace {
+
+class CollectionDataHashBindingHasherTracker : public BindingHasherTracker {
+ public:
+  explicit CollectionDataHashBindingHasherTracker(
+      StatusTracker* tracker, absl::string_view hard_binding_uri)
+      : tracker_(*ABSL_DIE_IF_NULL(tracker)),
+        hard_binding_uri_(hard_binding_uri) {}
+
+  void RecordMismatch() override {
+    tracker_.RecordFailure(FailureStatusCode::kAssertionCollectionHashMismatch,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordMalformed() override {
+    tracker_.RecordFailure(FailureStatusCode::kAssertionCollectionHashMalformed,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordAlgorithmUnsupported() override {
+    tracker_.RecordFailure(FailureStatusCode::kAlgorithmUnsupported,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordGeneralError(absl::Status error) override {
+    tracker_.RecordFailure(
+        FailureStatusCode::kGeneralError,
+        {.url = hard_binding_uri_, .explanation = error.message()});
+  }
+  void RecordInvalidUri() {
+    tracker_.RecordFailure(
+        FailureStatusCode::kAssertionCollectionHashInvalidUri,
+        {.url = hard_binding_uri_});
+  }
+  void RecordIncorrectFileCount() {
+    tracker_.RecordFailure(
+        FailureStatusCode::kAssertionCollectionHashIncorrectFileCount,
+        {.url = hard_binding_uri_});
+  }
+
+ private:
+  StatusTracker& tracker_;
+  absl::string_view hard_binding_uri_;
+};
+
+bool HasRelativePathComponents(absl::string_view uri_str) {
+  return uri_str == "." || uri_str == ".." || absl::StartsWith(uri_str, "./") ||
+         absl::StartsWith(uri_str, "../") ||
+         absl::StrContains(uri_str, "/./") ||
+         absl::StrContains(uri_str, "/../") || absl::EndsWith(uri_str, "/.") ||
+         absl::EndsWith(uri_str, "/..");
+}
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ValidationResultProto>>
+CollectionDataHashHardBindingValidator::Validate(
+    riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+    std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+    const {
+  const Assertion* absl_nullable assertion =
+      GetAssertion(partial_validation_result.get(),
+                   partial_validation_result->hard_binding_uri());
+  if (assertion == nullptr) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "missing assertion: ", partial_validation_result->hard_binding_uri()));
+  }
+  if (!assertion->has_collection_data_hash()) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("assertion is not a collection data hash assertion: ",
+                     partial_validation_result->hard_binding_uri()));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto tracker,
+                        DualStatusTracker::FromPartialValidationResult(
+                            partial_validation_result.get()));
+
+  Validate(contents, assertion->collection_data_hash(),
+           partial_validation_result->hard_binding_uri(), *tracker);
+
+  return MakeFullValidationResult(std::move(partial_validation_result));
+}
+
+void CollectionDataHashHardBindingValidator::Validate(
+    riegeli::Reader& contents, const CollectionDataHashAssertion& assertion,
+    absl::string_view hard_binding_uri, StatusTracker& tracker) const {
+  if (!contents.SupportsSize() || !contents.Size().has_value()) {
+    tracker.RecordFailure(
+        FailureStatusCode::kGeneralError,
+        {.url = hard_binding_uri,
+         .explanation = "Failed to get the size of the file"});
+    return;
+  }
+  uint64_t file_size = *contents.Size();
+
+  CollectionDataHashBindingHasherTracker hasher_tracker(&tracker,
+                                                        hard_binding_uri);
+  if (assertion.uris().empty()) {
+    hasher_tracker.RecordMalformed();
+    return;
+  }
+
+  auto alg = ParseHashAlgorithm(assertion.alg());
+  if (!alg.ok()) {
+    hasher_tracker.RecordAlgorithmUnsupported();
+    return;
+  }
+
+  auto zip_reader = ZipReader::Create(&contents);
+  if (!zip_reader.ok()) {
+    LOG(ERROR) << "Failed to open the file: " << zip_reader.status();
+    hasher_tracker.RecordGeneralError(
+        absl::InternalError("Failed to open the file"));
+    return;
+  }
+
+  std::vector<ZipReader::FileEntry> file_entries;
+  while ((*zip_reader)->HasNext()) {
+    auto entry = (*zip_reader)->Next();
+    if (!entry.ok()) {
+      hasher_tracker.RecordGeneralError(
+          absl::InternalError("Failed to read ZIP entry"));
+      return;
+    }
+    file_entries.push_back(*entry);
+  }
+
+  std::map<std::string, const UriHashedData*> expected_uris;
+  for (const auto& uri_data : assertion.uris()) {
+    if (uri_data.uri().empty() || uri_data.hash().empty()) {
+      hasher_tracker.RecordMalformed();
+      return;
+    }
+    // Validate that the URI has no relative path components (i.e. no "." or
+    // "..")
+    if (HasRelativePathComponents(uri_data.uri())) {
+      hasher_tracker.RecordInvalidUri();
+      return;
+    }
+    expected_uris[std::string(uri_data.uri())] = &uri_data;
+  }
+
+  // Validate URI hashes
+  const ZipReader::FileEntry* manifest_file_entry = nullptr;
+  for (const auto& entry : file_entries) {
+    if (entry.file_name == kZipManifestFileName) {
+      manifest_file_entry = &entry;
+      continue;
+    }
+    auto it = expected_uris.find(entry.file_name);
+    if (it == expected_uris.end()) {
+      hasher_tracker.RecordIncorrectFileCount();
+      return;
+    }
+    const auto& expected = *it->second;
+
+    auto file_hasher = InputHasher::Create(*alg);
+    if (!file_hasher.ok()) {
+      hasher_tracker.RecordGeneralError(file_hasher.status());
+      return;
+    }
+
+    uint32_t entry_start = entry.local_file_header_offset;
+    uint32_t entry_end = entry.file_range.offset + entry.file_range.length;
+    if (entry.has_data_descriptor) {
+      if (!contents.Seek(entry_end)) {
+        hasher_tracker.RecordGeneralError(
+            absl::InternalError("Failed to seek to data descriptor"));
+        return;
+      }
+      std::string sig;
+      if (!contents.Read(4, sig)) {
+        hasher_tracker.RecordGeneralError(
+            absl::InternalError("Failed to read data descriptor signature"));
+        return;
+      }
+      if (sig == kZipDataDescriptorSignature) {
+        entry_end += kDataDescriptorWithSignatureSize;
+      } else {
+        entry_end += kDataDescriptorSize;
+      }
+    }
+
+    auto update_status =
+        (*file_hasher)->Update(contents, entry_start, entry_end - entry_start);
+    if (!update_status.ok()) {
+      hasher_tracker.RecordGeneralError(update_status);
+      return;
+    }
+    std::string file_hash = (*file_hasher)->Digest();
+    if (expected.hash() != file_hash) {
+      hasher_tracker.RecordMismatch();
+      return;
+    }
+
+    expected_uris.erase(it);
+  }
+
+  if (!expected_uris.empty()) {
+    hasher_tracker.RecordIncorrectFileCount();
+    return;
+  }
+
+  // Validate ZIP central directory hash
+  if (manifest_file_entry == nullptr) {
+    hasher_tracker.RecordGeneralError(
+        absl::InternalError("Manifest file entry not found"));
+    return;
+  }
+  auto cd_hasher = InputHasher::Create(*alg);
+  if (!cd_hasher.ok()) {
+    hasher_tracker.RecordGeneralError(cd_hasher.status());
+    return;
+  }
+  uint32_t cd_offset = (*zip_reader)->central_directory_offset();
+  uint32_t manifest_crc32_offset =
+      manifest_file_entry->central_directory_header_offset + 16;
+
+  auto update_status =
+      (*cd_hasher)
+          ->Update(contents, cd_offset, manifest_crc32_offset - cd_offset);
+  if (!update_status.ok()) {
+    hasher_tracker.RecordGeneralError(update_status);
+    return;
+  }
+  update_status = (*cd_hasher)
+                      ->Update(contents, manifest_crc32_offset + 4,
+                               file_size - (manifest_crc32_offset + 4));
+  if (!update_status.ok()) {
+    hasher_tracker.RecordGeneralError(update_status);
+    return;
+  }
+
+  std::string cd_hash = (*cd_hasher)->Digest();
+  if (assertion.zip_central_directory_hash() != cd_hash) {
+    hasher_tracker.RecordMismatch();
+    return;
+  }
+
+  tracker.RecordSuccess(SuccessStatusCode::kAssertionCollectionHashMatch,
+                        {.url = hard_binding_uri});
+}
+
+}  // namespace credentio
diff --git a/bindings/collection_data_hash_hard_binding_validator.h b/bindings/collection_data_hash_hard_binding_validator.h
new file mode 100644
index 0000000..fdd197d
--- /dev/null
+++ b/bindings/collection_data_hash_hard_binding_validator.h
@@ -0,0 +1,49 @@
+// 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_BINDINGS_COLLECTION_DATA_HASH_HARD_BINDING_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_COLLECTION_DATA_HASH_HARD_BINDING_VALIDATOR_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "bindings/hard_binding_validator.h"
+#include "formats/asset_byte_info.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/status_tracker.h"
+
+namespace credentio {
+
+class CollectionDataHashHardBindingValidator : public HardBindingValidator {
+ public:
+  absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate(
+      riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+      std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+      const override;
+
+  // Validates the collection data hash assertion and records the result in the
+  // provided `tracker`.
+  void Validate(riegeli::Reader& contents,
+                const CollectionDataHashAssertion& assertion,
+                absl::string_view hard_binding_uri,
+                StatusTracker& tracker) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_COLLECTION_DATA_HASH_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/data_hash_hard_binding_validator.cc b/bindings/data_hash_hard_binding_validator.cc
new file mode 100644
index 0000000..d776271
--- /dev/null
+++ b/bindings/data_hash_hard_binding_validator.cc
@@ -0,0 +1,204 @@
+// 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/data_hash_hard_binding_validator.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "bindings/binding_hasher.h"
+#include "constants/status_codes.h"
+#include "formats/asset_byte_info.h"
+#include "formats/byte_range.h"
+#include "proto/assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/dual_status_tracker.h"
+#include "utils/status_tracker.h"
+#include "validator/result.h"
+#include "validator/validation_result_internal.h"
+
+namespace credentio {
+
+namespace {
+class DataHashBindingHasherTracker : public BindingHasherTracker {
+ public:
+  explicit DataHashBindingHasherTracker(StatusTracker* tracker,
+                                        absl::string_view hard_binding_uri)
+      : tracker_(*ABSL_DIE_IF_NULL(tracker)),
+        hard_binding_uri_(hard_binding_uri) {}
+  void RecordMismatch() override {
+    tracker_.RecordFailure(FailureStatusCode::kAssertionDataHashMismatch,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordMalformed() override {
+    tracker_.RecordFailure(FailureStatusCode::kAssertionDataHashMalformed,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordAlgorithmUnsupported() override {
+    tracker_.RecordFailure(FailureStatusCode::kAlgorithmUnsupported,
+                           {.url = hard_binding_uri_});
+  }
+  void RecordGeneralError(absl::Status error) override {
+    tracker_.RecordFailure(
+        FailureStatusCode::kGeneralError,
+        {.url = hard_binding_uri_, .explanation = error.message()});
+  }
+
+ private:
+  StatusTracker& tracker_;
+  absl::string_view hard_binding_uri_;
+};
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ValidationResultProto>>
+DataHashHardBindingValidator::Validate(
+    riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+    std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+    const {
+  const Assertion* absl_nullable assertion =
+      GetAssertion(partial_validation_result.get(),
+                   partial_validation_result->hard_binding_uri());
+  if (assertion == nullptr) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "missing assertion: ", partial_validation_result->hard_binding_uri()));
+  }
+  if (!assertion->has_data_hash()) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("assertion is not a data hash assertion: ",
+                     partial_validation_result->hard_binding_uri()));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto tracker,
+                        DualStatusTracker::FromPartialValidationResult(
+                            partial_validation_result.get()));
+  Validate(contents, asset_byte_info.manifest_store_location,
+           assertion->data_hash(),
+           partial_validation_result->hard_binding_uri(), *tracker,
+           tracker->WritesToIngredientManifest());
+
+  return MakeFullValidationResult(std::move(partial_validation_result));
+}
+
+void DataHashHardBindingValidator::Validate(
+    riegeli::Reader& contents, std::optional<ByteRange> manifest_store_location,
+    const DataHashAssertion& assertion, absl::string_view hard_binding_uri,
+    StatusTracker& tracker, bool assertion_in_ingredient_manifest,
+    uint64_t start_offset, int64_t end_offset) const {
+  std::vector<BindingHasher::Chunk> exclusions;
+
+  if (!contents.SupportsSize() || !contents.Size().has_value()) {
+    tracker.RecordFailure(FailureStatusCode::kGoogleInternalError,
+                          {.url = hard_binding_uri,
+                           .explanation = "Asset size cannot be determined"});
+    return;
+  }
+
+  const int64_t c2pa_start = manifest_store_location.has_value()
+                                 ? manifest_store_location->offset
+                                 : -1;
+  const uint64_t asset_end_offset =
+      end_offset == -1 ? *contents.Size() : end_offset;
+
+  uint64_t valid_next_exclusion_start = start_offset;
+  int64_t offset_adjustment = 0;
+  bool has_additional_exclusions = assertion.exclusions_size() > 1;
+  for (const auto& exclusion : assertion.exclusions()) {
+    if (exclusion.start() < 0 || exclusion.length() < 0) {
+      tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMalformed,
+                            {.url = hard_binding_uri});
+      return;
+    }
+    const uint64_t ex_start =
+        start_offset + exclusion.start() + offset_adjustment;
+    int64_t ex_length = exclusion.length();
+    if (assertion_in_ingredient_manifest && ex_start == c2pa_start) {
+      // Note: fluffy_puppy.webp manifest exclusion range is 1 byte shorter than
+      // the box and if this case is triggered, it would fail validation.
+      ex_length = manifest_store_location->length;
+      offset_adjustment = manifest_store_location->length - exclusion.length();
+    }
+
+    // ex_start is now with respect to the start of the file
+    if (ex_start < start_offset || exclusion.length() < 0) {
+      // Starts before the start of this asset or negative length.
+      tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMalformed,
+                            {.url = hard_binding_uri});
+      return;
+    }
+    if (ex_start < valid_next_exclusion_start) {
+      // Starts before the end of the previous exclusion range.
+      tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMalformed,
+                            {.url = hard_binding_uri});
+      return;
+    }
+    valid_next_exclusion_start = ex_start + ex_length;
+
+    if (ex_start + ex_length > asset_end_offset) {
+      // Ends past the end of the asset.
+      tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMismatch,
+                            {.url = hard_binding_uri});
+      return;
+    }
+
+    exclusions.push_back({.op = BindingHasher::Chunk::Op::kExclusion,
+                          .offset = ex_start,
+                          .length = ex_length});
+
+    if (ex_start < c2pa_start) {
+      has_additional_exclusions = true;
+    }
+  }
+  if (manifest_store_location.has_value() && has_additional_exclusions) {
+    tracker.RecordInformational(
+        InformationalStatusCode::kAssertionDataHashAdditionalExclusionsPresent,
+        {.url = hard_binding_uri});
+  }
+
+  DataHashBindingHasherTracker data_hash_tracker(&tracker, hard_binding_uri);
+  auto hasher =
+      BindingHasher::Create(assertion.alg(), exclusions, data_hash_tracker);
+  if (hasher == nullptr) {
+    return;
+  }
+
+  auto digest =
+      hasher->Digest(contents, data_hash_tracker, start_offset, end_offset);
+  if (!digest.has_value()) {
+    return;
+  }
+
+  if (*digest != assertion.hash()) {
+    tracker.RecordFailure(FailureStatusCode::kAssertionDataHashMismatch,
+                          {.url = hard_binding_uri});
+    return;
+  }
+
+  tracker.RecordSuccess(SuccessStatusCode::kAssertionDataHashMatch,
+                        {.url = hard_binding_uri});
+}
+
+}  // namespace credentio
diff --git a/bindings/data_hash_hard_binding_validator.h b/bindings/data_hash_hard_binding_validator.h
new file mode 100644
index 0000000..1b55e45
--- /dev/null
+++ b/bindings/data_hash_hard_binding_validator.h
@@ -0,0 +1,60 @@
+// 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_BINDINGS_DATA_HASH_HARD_BINDING_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_DATA_HASH_HARD_BINDING_VALIDATOR_H_
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "bindings/hard_binding_validator.h"
+#include "formats/asset_byte_info.h"
+#include "formats/byte_range.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/status_tracker.h"
+
+namespace credentio {
+
+class DataHashHardBindingValidator : public HardBindingValidator {
+ public:
+  absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate(
+      riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+      std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+      const override;
+
+  // Validates the data hash assertion. The start_offset is the offset of the
+  // first byte of the data to validate. The end_offset is the offset of the
+  // last byte of the data to validate, -1 means the end of the file. The
+  // manifest_store_location is the offset and length of the manifest store
+  // based on the entire asset. The offsets specified within the assertion's
+  // exclusions are offsets based upon the start of the portion of the asset
+  // corresponding to the binding. The offset to exclude that is sent to the
+  // hasher is the assertion's exclusion offset PLUS the start_offset.
+  void Validate(riegeli::Reader& contents,
+                std::optional<ByteRange> manifest_store_location,
+                const DataHashAssertion& assertion,
+                absl::string_view hard_binding_uri, StatusTracker& tracker,
+                bool assertion_in_ingredient_manifest,
+                uint64_t start_offset = 0, int64_t end_offset = -1) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_DATA_HASH_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/hard_binding_validator.h b/bindings/hard_binding_validator.h
new file mode 100644
index 0000000..438d710
--- /dev/null
+++ b/bindings/hard_binding_validator.h
@@ -0,0 +1,42 @@
+// 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_BINDINGS_HARD_BINDING_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_HARD_BINDING_VALIDATOR_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/asset_byte_info.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+class HardBindingValidator {
+ public:
+  virtual ~HardBindingValidator() = default;
+
+  virtual absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate(
+      riegeli::Reader& contents, const AssetByteInfo& asset_byte_info,
+      std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+      const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_HARD_BINDING_VALIDATOR_H_
diff --git a/bindings/input_hasher.cc b/bindings/input_hasher.cc
new file mode 100644
index 0000000..e191bfb
--- /dev/null
+++ b/bindings/input_hasher.cc
@@ -0,0 +1,99 @@
+// 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/input_hasher.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+constexpr uint64_t kMaxChunkBytes = 1024 * 1024;  // 1 MiB
+
+absl::Status AddToHash(Hasher& hasher, riegeli::Reader& asset,
+                       uint64_t read_start, uint64_t read_length) {
+  if (!asset.Seek(read_start) || asset.pos() != read_start) {
+    return absl::InternalError(absl::Substitute(
+        "Failed to seek to $0 in the file. Current position is "
+        "$1.",
+        read_start, asset.pos()));
+  }
+
+  for (uint64_t i = 0; i < read_length; i += kMaxChunkBytes) {
+    uint64_t length = std::min(read_length - i, kMaxChunkBytes);
+
+    std::string contents;
+    if (!asset.Read(length, contents)) {
+      return asset.StatusOrAnnotate(
+          absl::DataLossError("Failed to read from source"));
+    }
+    hasher.Update(contents);
+  }
+  return absl::OkStatus();
+}
+
+class SimpleInputHasher : public InputHasher {
+ public:
+  explicit SimpleInputHasher(std::unique_ptr<Hasher> absl_nonnull hasher)
+      : hasher_(std::move(hasher)) {}
+
+  absl::Status Update(riegeli::Reader& input, uint64_t offset,
+                      uint64_t length) override {
+    return AddToHash(*hasher_, input, offset, length);
+  }
+
+  void Update(absl::string_view data) override { hasher_->Update(data); }
+
+  std::string Digest() override { return hasher_->Digest(); }
+
+ private:
+  std::unique_ptr<Hasher> hasher_;
+};
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<InputHasher> absl_nonnull> InputHasher::Create(
+    absl::string_view alg) {
+  ABSL_ASSIGN_OR_RETURN(auto hasher, CreateHasher(alg));
+  return std::make_unique<SimpleInputHasher>(std::move(hasher));
+}
+
+absl::StatusOr<std::unique_ptr<InputHasher> absl_nonnull> InputHasher::Create(
+    HashAlgorithm alg) {
+  ABSL_ASSIGN_OR_RETURN(auto hasher, CreateHasher(alg));
+  return std::make_unique<SimpleInputHasher>(std::move(hasher));
+}
+
+absl::StatusOr<std::unique_ptr<InputHasher>> InputHasher::Create(
+    std::unique_ptr<Hasher> absl_nonnull hasher) {
+  return std::make_unique<SimpleInputHasher>(std::move(hasher));
+}
+
+}  // namespace credentio
diff --git a/bindings/input_hasher.h b/bindings/input_hasher.h
new file mode 100644
index 0000000..8de914c
--- /dev/null
+++ b/bindings/input_hasher.h
@@ -0,0 +1,60 @@
+// 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_BINDINGS_INPUT_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_INPUT_HASHER_H_
+
+#include <stdint.h>
+
+#include <memory>
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// A simple hasher for C2PA hash computing.
+class InputHasher {
+ public:
+  virtual ~InputHasher() = default;
+
+  // Reads `length` bytes from `input` starting at `offset` and updates the
+  // hash.
+  virtual absl::Status Update(riegeli::Reader& input, uint64_t offset,
+                              uint64_t length) = 0;
+
+  // Updates the hash with `data`.
+  virtual void Update(absl::string_view data) = 0;
+
+  // Returns the hash digest.
+  virtual std::string Digest() = 0;
+
+  static absl::StatusOr<std::unique_ptr<InputHasher> absl_nonnull> Create(
+      absl::string_view alg);
+  static absl::StatusOr<std::unique_ptr<InputHasher> absl_nonnull> Create(
+      HashAlgorithm alg);
+  static absl::StatusOr<std::unique_ptr<InputHasher>> Create(
+      std::unique_ptr<Hasher> absl_nonnull hasher);
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_INPUT_HASHER_H_
diff --git a/bindings/input_hasher_test.cc b/bindings/input_hasher_test.cc
new file mode 100644
index 0000000..9839bc5
--- /dev/null
+++ b/bindings/input_hasher_test.cc
@@ -0,0 +1,189 @@
+// 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/input_hasher.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+using ::testing::Return;
+
+std::string Sha256Digest(absl::string_view data) {
+  absl::StatusOr<std::unique_ptr<Hasher>> hasher = CreateHasher("sha256");
+  if (!hasher.ok()) return "";
+  (*hasher)->Update(data);
+  return (*hasher)->Digest();
+}
+
+TEST(InputHasherTest, CreateWithValidAlgString) {
+  EXPECT_THAT(InputHasher::Create("sha256"), IsOk());
+  EXPECT_THAT(InputHasher::Create("sha384"), IsOk());
+  EXPECT_THAT(InputHasher::Create("sha512"), IsOk());
+}
+
+TEST(InputHasherTest, CreateWithInvalidAlgString) {
+  EXPECT_THAT(InputHasher::Create("invalid"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(InputHasherTest, CreateWithValidAlgEnum) {
+  EXPECT_THAT(InputHasher::Create(HashAlgorithm::kSha256), IsOk());
+  EXPECT_THAT(InputHasher::Create(HashAlgorithm::kSha384), IsOk());
+  EXPECT_THAT(InputHasher::Create(HashAlgorithm::kSha512), IsOk());
+}
+
+class MockHasher : public Hasher {
+ public:
+  MOCK_METHOD(void, Update, (absl::string_view data), (override));
+  MOCK_METHOD(std::string, Digest, (), (override));
+};
+
+TEST(InputHasherTest, CreateWithCustomHasher) {
+  auto mock_hasher = std::make_unique<MockHasher>();
+  EXPECT_CALL(*mock_hasher, Update("test"));
+  EXPECT_CALL(*mock_hasher, Digest()).WillOnce(Return("mock_digest"));
+
+  auto input_hasher = InputHasher::Create(std::move(mock_hasher));
+  ASSERT_THAT(input_hasher, IsOk());
+  (*input_hasher)->Update("test");
+  EXPECT_THAT((*input_hasher)->Digest(), Eq("mock_digest"));
+}
+
+TEST(InputHasherTest, UpdateStringView) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  (*hasher)->Update("hello");
+  (*hasher)->Update(" ");
+  (*hasher)->Update("world");
+
+  EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()),
+              Eq(absl::BytesToHexString(Sha256Digest("hello world"))));
+}
+
+TEST(InputHasherTest, UpdateRiegeliEmpty) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  std::string data = "";
+  riegeli::StringReader reader(data);
+  EXPECT_THAT((*hasher)->Update(reader, 0, 0), IsOk());
+
+  EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()),
+              Eq(absl::BytesToHexString(Sha256Digest(data))));
+}
+
+TEST(InputHasherTest, UpdateRiegeliSmall) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  std::string data = "small data";
+  riegeli::StringReader reader(data);
+  EXPECT_THAT((*hasher)->Update(reader, 0, data.length()), IsOk());
+
+  EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()),
+              Eq(absl::BytesToHexString(Sha256Digest(data))));
+}
+
+TEST(InputHasherTest, UpdateRiegeliLarge) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  std::string data(20000, 'a');  // Larger than default chunk size
+  riegeli::StringReader reader(data);
+  EXPECT_THAT((*hasher)->Update(reader, 0, data.length()), IsOk());
+
+  EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()),
+              Eq(absl::BytesToHexString(Sha256Digest(data))));
+}
+
+TEST(InputHasherTest, UpdateRiegeliWithOffset) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  std::string data = "prefix_and_data";
+  riegeli::StringReader reader(data);
+  EXPECT_THAT((*hasher)->Update(reader, 7, 3), IsOk());  // "and"
+
+  EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()),
+              Eq(absl::BytesToHexString(Sha256Digest("and"))));
+}
+
+TEST(InputHasherTest, UpdateRiegeliMultiple) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  std::string data1 = "first part";
+  std::string data2 = " second part";
+
+  riegeli::StringReader reader1(data1);
+  riegeli::StringReader reader2(data2);
+
+  EXPECT_THAT((*hasher)->Update(reader1, 0, data1.length()), IsOk());
+  (*hasher)->Update(" middle ");
+  EXPECT_THAT((*hasher)->Update(reader2, 0, data2.length()), IsOk());
+
+  EXPECT_THAT(absl::BytesToHexString((*hasher)->Digest()),
+              Eq(absl::BytesToHexString(
+                  Sha256Digest("first part middle  second part"))));
+}
+
+TEST(InputHasherTest, UpdateRiegeliClosedReaderFails) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  riegeli::StringReader reader("test");
+  reader.Close();
+
+  EXPECT_THAT((*hasher)->Update(reader, 0, 4),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       HasSubstr("Object closed")));
+}
+
+TEST(InputHasherTest, UpdateRiegeliOffsetOutOfRange) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  riegeli::StringReader reader("test");
+
+  EXPECT_THAT(
+      (*hasher)->Update(reader, 10, 4),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("Failed to seek to 10")));
+}
+
+TEST(InputHasherTest, UpdateRiegeliLengthExceedsSourceFails) {
+  auto hasher = InputHasher::Create("sha256");
+  ASSERT_THAT(hasher, IsOk());
+  riegeli::StringReader reader("test");
+
+  EXPECT_THAT((*hasher)->Update(reader, 0, 100),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("Failed to read from source")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/bindings/merkle.cc b/bindings/merkle.cc
new file mode 100644
index 0000000..237a0f1
--- /dev/null
+++ b/bindings/merkle.cc
@@ -0,0 +1,330 @@
+// 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.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#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 "crypto/hash.h"
+#include "google/protobuf/repeated_field.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+const int64_t kMaxPowerOfTwo = 0x8000'0000;
+const int64_t kMaxBlockCount = 1048576;
+
+absl::StatusOr<int64_t> NextPowerOfTwo(int64_t value) {
+  if ((value & (value - 1)) == 0) {
+    // value is already a power of two
+    return value;
+  }
+  if (value < 0 || value >= kMaxPowerOfTwo) {
+    return absl::InvalidArgumentError(
+        absl::StrFormat("Value is negative or too large: %d", value));
+  }
+  int64_t next_power_of_two = 1;
+  while (next_power_of_two < value) {
+    next_power_of_two <<= 1;
+  }
+  return next_power_of_two;
+}
+
+absl::Status PadToPowerOfTwo(std::vector<std::string>& entries) {
+  ABSL_ASSIGN_OR_RETURN(auto next_power_of_two, NextPowerOfTwo(entries.size()));
+  if (next_power_of_two > entries.size()) {
+    entries.resize(next_power_of_two, "");
+  }
+  return absl::OkStatus();
+}
+
+absl::StatusOr<std::vector<std::string>> ReduceLayer(
+    HasherFactory* factory, std::vector<std::string> layer) {
+  std::vector<std::string> reduced_layer;
+  for (int64_t i = 0; i + 1 < layer.size(); i += 2) {
+    ABSL_ASSIGN_OR_RETURN(auto hasher, factory->Create());
+    hasher->Update(layer[i]);
+    hasher->Update(layer[i + 1]);
+    reduced_layer.push_back(hasher->Digest());
+  }
+  return reduced_layer;
+}
+
+absl::StatusOr<MerkleTree> PopulateLayers(
+    HasherFactory* factory, std::vector<std::string> leaf_hashes) {
+  MerkleTree tree = {leaf_hashes};
+  while (tree[tree.size() - 1].size() > 1) {
+    ABSL_ASSIGN_OR_RETURN(auto reduced_layer,
+                          ReduceLayer(factory, tree.back()));
+    tree.push_back(std::move(reduced_layer));
+  }
+  return tree;
+}
+
+// Returns the number of rows between leaf_row_size and hashes_row_size.
+// For example, if leaf_row_size is 16 and hashes_row_size is 4, the function
+// will return 4.
+absl::StatusOr<int64_t> RowsAway(int64_t leaf_row_size,
+                                 int64_t hashes_row_size) {
+  if (leaf_row_size <= 0 || hashes_row_size <= 0) {
+    return absl::InvalidArgumentError(
+        "leaf_row_size and hashes_row_size must be positive");
+  }
+  if (hashes_row_size > leaf_row_size) {
+    return absl::InvalidArgumentError(
+        "hashes row size is larger than the leaf row size");
+  }
+  ABSL_ASSIGN_OR_RETURN(auto leaf_pow2, NextPowerOfTwo(leaf_row_size));
+  ABSL_ASSIGN_OR_RETURN(auto hashes_pow2, NextPowerOfTwo(hashes_row_size));
+  int64_t rows_away = 0;
+  while (leaf_pow2 != hashes_pow2) {
+    leaf_pow2 >>= 1;
+    rows_away++;
+  }
+  return rows_away;
+}
+
+absl::StatusOr<std::vector<int64_t>> GetFixedBlockSizes(
+    uint64_t mdat_box_size, int64_t fixed_block_size) {
+  if (fixed_block_size <= 0) {
+    return absl::InvalidArgumentError(
+        "merkle map has an invalid fixed block size");
+  }
+
+  uint64_t num_blocks = mdat_box_size / fixed_block_size;
+  if (mdat_box_size % fixed_block_size != 0) {
+    num_blocks++;
+  }
+
+  if (num_blocks > kMaxBlockCount) {
+    return absl::InvalidArgumentError(absl::StrFormat(
+        "merkle map block count exceeds maximum allowable limit: %d > %d",
+        num_blocks, kMaxBlockCount));
+  }
+
+  std::vector<int64_t> block_sizes;
+  block_sizes.reserve(num_blocks);
+  block_sizes.insert(block_sizes.end(), mdat_box_size / fixed_block_size,
+                     fixed_block_size);
+  if (mdat_box_size % fixed_block_size != 0) {
+    block_sizes.push_back(mdat_box_size % fixed_block_size);
+  }
+  return block_sizes;
+}
+
+absl::StatusOr<std::vector<int64_t>> GetVariableBlockSizes(
+    uint64_t mdat_box_size,
+    const google::protobuf::RepeatedField<int64_t>& variable_block_sizes) {
+  if (variable_block_sizes.size() > kMaxBlockCount) {
+    return absl::InvalidArgumentError(absl::StrFormat(
+        "merkle map block count exceeds maximum allowable limit: %d > %d",
+        variable_block_sizes.size(), kMaxBlockCount));
+  }
+
+  int64_t total_block_size = 0;
+  std::vector<int64_t> block_sizes;
+  block_sizes.reserve(variable_block_sizes.size());
+  for (int64_t block_size : variable_block_sizes) {
+    if (block_size <= 0) {
+      return absl::InvalidArgumentError(
+          "merkle map has an invalid variable block size");
+    }
+    if (total_block_size > INT64_MAX - block_size) {
+      return absl::InvalidArgumentError(
+          "merkle map's variable block sizes overflow int64_t");
+    }
+    total_block_size += block_size;
+    block_sizes.push_back(block_size);
+  }
+  if (total_block_size != mdat_box_size) {
+    return absl::InvalidArgumentError(
+        "merkle map's variable block sizes do not sum to the mdat box size");
+  }
+  return block_sizes;
+}
+
+absl::StatusOr<std::vector<int64_t>> GetSingleBoxSizes(uint64_t mdat_box_size) {
+  if (mdat_box_size > INT64_MAX) {
+    return absl::InvalidArgumentError(
+        "mdat atom's box size is too large to fit in an int64_t");
+  }
+  return std::vector<int64_t>{static_cast<int64_t>(mdat_box_size)};
+}
+
+}  // namespace
+
+absl::StatusOr<std::vector<int64_t>> DeriveMerkleBlockSizes(
+    const BmffMerkle& merkle, uint64_t mdat_box_size) {
+  if (merkle.has_fixed_block_size() && merkle.variable_block_sizes_size() > 0) {
+    return absl::InvalidArgumentError(
+        "merkle map has both fixed and variable block sizes");
+  }
+
+  std::vector<int64_t> block_sizes;
+  if (merkle.has_fixed_block_size()) {
+    int64_t fixed_block_size = merkle.fixed_block_size();
+    if (fixed_block_size <= 0) {
+      return absl::InvalidArgumentError(
+          "merkle map has an invalid fixed block size");
+    }
+    uint64_t num_blocks = mdat_box_size / fixed_block_size;
+    if (mdat_box_size % fixed_block_size != 0) {
+      num_blocks++;
+    }
+    if (num_blocks != merkle.count()) {
+      return absl::InvalidArgumentError(
+          "merkle map's block sizes do not match the expected leaf count");
+    }
+
+    ABSL_ASSIGN_OR_RETURN(
+        block_sizes,
+        GetFixedBlockSizes(mdat_box_size, merkle.fixed_block_size()));
+  } else if (merkle.variable_block_sizes_size() > 0) {
+    ABSL_ASSIGN_OR_RETURN(
+        block_sizes,
+        GetVariableBlockSizes(mdat_box_size, merkle.variable_block_sizes()));
+  } else {
+    ABSL_ASSIGN_OR_RETURN(block_sizes, GetSingleBoxSizes(mdat_box_size));
+  }
+
+  if (block_sizes.size() != merkle.count()) {
+    return absl::InvalidArgumentError(
+        "merkle map's block sizes do not match the expected leaf count");
+  }
+
+  return block_sizes;
+}
+
+absl::StatusOr<DerivedTreeData> DeriveMerkleTreeData(int64_t leaf_count,
+                                                     int64_t hashes_count,
+                                                     int64_t auxiliary_count) {
+  ABSL_ASSIGN_OR_RETURN(auto leaf_pow2, NextPowerOfTwo(leaf_count));
+  ABSL_ASSIGN_OR_RETURN(auto hashes_pow2, NextPowerOfTwo(hashes_count));
+  ABSL_ASSIGN_OR_RETURN(auto delta_rows, RowsAway(leaf_pow2, hashes_pow2));
+  int64_t expected_hashes_count = leaf_count;
+  for (int64_t r = 0; r < delta_rows; ++r) {
+    expected_hashes_count = (expected_hashes_count + 1) >> 1;
+  }
+
+  if (hashes_count != expected_hashes_count) {
+    return absl::InvalidArgumentError(absl::StrFormat(
+        "hashes count is not equal to the expected hashes count: "
+        "Hashes Count: %d, Leaf Count: %d, Expected Hashes Count: %d",
+        hashes_count, leaf_count, expected_hashes_count));
+  }
+
+  if ((delta_rows > 0 || auxiliary_count > 0) &&
+      auxiliary_count != leaf_count) {
+    return absl::InvalidArgumentError(
+        absl::StrFormat("incorrect count of auxiliary data boxes: "
+                        "Auxiliary Data Count: %d, Leaf Count: %d",
+                        auxiliary_count, leaf_count));
+  }
+
+  return DerivedTreeData{
+      .full_leaf_count = leaf_pow2,
+      .full_hashes_count = hashes_pow2,
+      .delta_rows = delta_rows,
+  };
+}
+
+absl::StatusOr<MerkleTree> CreateMerkleTree(HasherFactory* factory,
+                                            riegeli::Reader& input,
+                                            int64_t starting_offset,
+                                            int64_t length,
+                                            CreateMerkleTreeOptions options) {
+  if (options.fixed_block_size.has_value() &&
+      !options.variable_block_sizes.empty()) {
+    return absl::InvalidArgumentError(
+        "Must specify a fixed block size or variable block sizes, not both.");
+  }
+
+  if (!options.fixed_block_size.has_value() &&
+      options.variable_block_sizes.empty()) {
+    // The entire input is a single leaf node.
+    return CreateMerkleTree(factory, input, starting_offset, length,
+                            std::vector<int64_t>{length});
+  }
+
+  if (options.fixed_block_size.has_value()) {
+    if (options.fixed_block_size.value() <= 0) {
+      return absl::InvalidArgumentError(
+          absl::StrFormat("Fixed block size must be greater than 0. Found: %d",
+                          options.fixed_block_size.value()));
+    }
+    std::vector<int64_t> block_sizes(length / options.fixed_block_size.value(),
+                                     options.fixed_block_size.value());
+    if (length % options.fixed_block_size.value() != 0) {
+      block_sizes.push_back(length % options.fixed_block_size.value());
+    }
+    return CreateMerkleTree(factory, input, starting_offset, length,
+                            block_sizes);
+  }
+
+  return CreateMerkleTree(factory, input, starting_offset, length,
+                          options.variable_block_sizes);
+}
+
+absl::StatusOr<MerkleTree> CreateMerkleTree(HasherFactory* factory,
+                                            riegeli::Reader& input,
+                                            int64_t starting_offset,
+                                            int64_t length,
+                                            std::vector<int64_t> block_sizes) {
+  int64_t total_blocks_length = 0;
+  std::vector<std::string> leaf_hashes;
+  leaf_hashes.reserve(block_sizes.size());
+  if (!input.Seek(starting_offset) || input.pos() != starting_offset) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to starting offset"));
+  }
+  for (int64_t block_size : block_sizes) {
+    if (block_size <= 0) {
+      return absl::InvalidArgumentError(absl::StrFormat(
+          "Block size must be greater than 0. Found: %d", block_size));
+    }
+    total_blocks_length += block_size;
+    if (total_blocks_length > length) {
+      return absl::InvalidArgumentError(absl::StrFormat(
+          "Total block size is too large. Total Block size: %d, Length: %d",
+          total_blocks_length, length));
+    }
+    std::string leaf_data;
+    if (!input.Read(block_size, leaf_data)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read leaf data"));
+    }
+    ABSL_ASSIGN_OR_RETURN(auto hasher, factory->Create());
+    hasher->Update(leaf_data);
+    leaf_hashes.push_back(hasher->Digest());
+  }
+  ABSL_RETURN_IF_ERROR(PadToPowerOfTwo(leaf_hashes));
+
+  return PopulateLayers(factory, leaf_hashes);
+}
+
+}  // namespace credentio
diff --git a/bindings/merkle.h b/bindings/merkle.h
new file mode 100644
index 0000000..365b2ca
--- /dev/null
+++ b/bindings/merkle.h
@@ -0,0 +1,81 @@
+// 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_BINDINGS_MERKLE_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "crypto/hash.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+struct DerivedTreeData {
+  int64_t full_leaf_count;
+  int64_t full_hashes_count;
+  int64_t delta_rows;
+
+  bool operator==(const DerivedTreeData& other) const {
+    return full_leaf_count == other.full_leaf_count &&
+           full_hashes_count == other.full_hashes_count &&
+           delta_rows == other.delta_rows;
+  }
+};
+
+// Derives the data needed to validate a Merkle Tree from the leaf count, hashes
+// count, and auxiliary data count.
+absl::StatusOr<DerivedTreeData> DeriveMerkleTreeData(int64_t leaf_count,
+                                                     int64_t hashes_count,
+                                                     int64_t auxiliary_count);
+
+// Derives the block sizes for a given Merkle map and total leaf data size.
+absl::StatusOr<std::vector<int64_t>> DeriveMerkleBlockSizes(
+    const BmffMerkle& merkle, uint64_t mdat_box_size);
+
+using MerkleTree = std::vector<std::vector<std::string>>;
+
+// Options for creating a Merkle Tree.
+// - `fixed_block_size`: blocks of equal sizes
+// - `variable_block_sizes`: blocks of the specified sizes
+// - neither: the entire input string will treated as a single block
+// - both: then an error will be returned
+struct CreateMerkleTreeOptions {
+  std::optional<int64_t> fixed_block_size = std::nullopt;
+  std::vector<int64_t> variable_block_sizes = {};
+};
+
+// Creates a Merkle Tree from a riegeli::Reader with the options indicating
+// how the input should be broken into blocks.
+absl::StatusOr<MerkleTree> CreateMerkleTree(
+    HasherFactory* factory, riegeli::Reader& input, int64_t starting_offset,
+    int64_t length, CreateMerkleTreeOptions options = {});
+
+// Creates a Merkle Tree from a vector of strings, each which represent a single
+// leaf node.
+absl::StatusOr<MerkleTree> CreateMerkleTree(HasherFactory* factory,
+                                            riegeli::Reader& input,
+                                            int64_t starting_offset,
+                                            int64_t length,
+                                            std::vector<int64_t> block_sizes);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_H_
diff --git a/bindings/merkle_test.cc b/bindings/merkle_test.cc
new file mode 100644
index 0000000..cda2fb3
--- /dev/null
+++ b/bindings/merkle_test.cc
@@ -0,0 +1,474 @@
+// 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.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <variant>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+class JoiningHasher : public Hasher {
+ public:
+  void Update(absl::string_view content) override {
+    digest_ = absl::StrCat(digest_, content);
+  }
+
+  std::string Digest() override { return digest_; }
+
+ protected:
+  std::string digest_ = "";
+};
+
+class JoiningHasherFactory : public HasherFactory {
+ public:
+  absl::StatusOr<std::unique_ptr<Hasher>> Create() const override {
+    return std::make_unique<JoiningHasher>();
+  }
+  HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; }
+};
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+
+struct BlockSizesCase {
+  std::string name;
+  BmffMerkle merkle_map;
+  uint64_t mdat_size;
+  absl::StatusOr<std::vector<int64_t>> expected;
+};
+
+using DeriveBlockSizesTest = testing::TestWithParam<BlockSizesCase>;
+
+TEST_P(DeriveBlockSizesTest, DerivesBlockSizes) {
+  const BlockSizesCase& params = GetParam();
+
+  if (params.expected.ok()) {
+    EXPECT_THAT(DeriveMerkleBlockSizes(params.merkle_map, params.mdat_size),
+                IsOkAndHolds(Eq(params.expected.value())));
+  } else {
+    EXPECT_THAT(DeriveMerkleBlockSizes(params.merkle_map, params.mdat_size),
+                StatusIs(params.expected.status().code(),
+                         params.expected.status().message()));
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    DeriveMerkleDataTests, DeriveBlockSizesTest,
+    ::testing::Values(
+        BlockSizesCase{
+            .name = "BothSizeTypes",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 5
+              fixed_block_size: 10
+              variable_block_sizes: 1
+              variable_block_sizes: 3
+            )pb"),
+            .mdat_size = 10,
+            .expected = absl::InvalidArgumentError(
+                "merkle map has both fixed and variable block sizes"),
+        },
+        BlockSizesCase{
+            .name = "FixedSizeZero",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 5
+              fixed_block_size: 0
+            )pb"),
+            .mdat_size = 10,
+            .expected = absl::InvalidArgumentError(
+                "merkle map has an invalid fixed block size"),
+        },
+        BlockSizesCase{
+            .name = "FixedSizeBlocksNotMatchingCount",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 3
+              fixed_block_size: 3
+            )pb"),
+            .mdat_size = 10,
+            .expected = absl::InvalidArgumentError(
+                "merkle map's block sizes do not "
+                "match the expected leaf count"),
+        },
+        BlockSizesCase{
+            .name = "VariableHasInvalidSize",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 4
+              variable_block_sizes: 3
+              variable_block_sizes: 0
+              variable_block_sizes: 2
+            )pb"),
+            .mdat_size = 10,
+            .expected = absl::InvalidArgumentError(
+                "merkle map has an invalid variable block size"),
+        },
+        BlockSizesCase{
+            .name = "VariableBlockSumTooLarge",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 4
+              variable_block_sizes: 3
+              variable_block_sizes: 4
+              variable_block_sizes: 2
+              variable_block_sizes: 6
+            )pb"),
+            .mdat_size = 10,
+            .expected = absl::InvalidArgumentError(
+                "merkle map's variable block sizes "
+                "do not sum to the mdat box size"),
+        },
+        BlockSizesCase{
+            .name = "ValidVariableBlockSizes",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 4
+              variable_block_sizes: 3
+              variable_block_sizes: 2
+              variable_block_sizes: 3
+              variable_block_sizes: 2
+            )pb"),
+            .mdat_size = 10,
+            .expected = std::vector<int64_t>{3, 2, 3, 2},
+        },
+        BlockSizesCase{
+            .name = "NoBlockSizesSpecifiedTooLargeMdat",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 1
+            )pb"),
+            .mdat_size = static_cast<uint64_t>(INT64_MAX) + 1,
+            .expected = absl::InvalidArgumentError(
+                "mdat atom's box size is too large to fit in an int64_t"),
+        },
+        BlockSizesCase{
+            .name = "FixedBlockSizeExceedsLimit",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 1048577
+              fixed_block_size: 1
+            )pb"),
+            .mdat_size = 1048577,
+            .expected = absl::InvalidArgumentError(
+                "merkle map block count exceeds maximum allowable limit: "
+                "1048577 > 1048576"),
+        },
+        BlockSizesCase{
+            .name = "VariableBlockSizesOverflow",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 2
+              variable_block_sizes: 9223372036854775807
+              variable_block_sizes: 1
+            )pb"),
+            .mdat_size = 10,
+            .expected = absl::InvalidArgumentError(
+                "merkle map's variable block sizes overflow int64_t"),
+        },
+        BlockSizesCase{
+            .name = "ValidNoBlockSizesSpecified",
+            .merkle_map = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+              count: 1
+            )pb"),
+            .mdat_size = 100,
+            .expected = std::vector<int64_t>{100},
+        }),
+    [](const testing::TestParamInfo<BlockSizesCase>& info) {
+      return info.param.name;
+    });
+
+TEST(MerkleTest, VariableBlockSizesExceedsLimit) {
+  BmffMerkle merkle_map;
+  merkle_map.set_count(1048577);
+  for (int i = 0; i < 1048577; ++i) {
+    merkle_map.add_variable_block_sizes(1);
+  }
+  EXPECT_THAT(DeriveMerkleBlockSizes(merkle_map, 1048577),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("merkle map block count exceeds maximum "
+                                 "allowable limit: 1048577 > 1048576")));
+}
+
+struct TreeDataCase {
+  std::string name;
+  int64_t leaf_count;
+  int64_t hashes_count;
+  int64_t auxiliary_count;
+  absl::StatusOr<DerivedTreeData> expected;
+};
+
+using DeriveTreeDataTest = testing::TestWithParam<TreeDataCase>;
+
+TEST_P(DeriveTreeDataTest, DerivesTreeData) {
+  const TreeDataCase& params = GetParam();
+
+  if (params.expected.ok()) {
+    EXPECT_THAT(DeriveMerkleTreeData(params.leaf_count, params.hashes_count,
+                                     params.auxiliary_count),
+                IsOkAndHolds(Eq(params.expected.value())));
+  } else {
+    EXPECT_THAT(DeriveMerkleTreeData(params.leaf_count, params.hashes_count,
+                                     params.auxiliary_count),
+                StatusIs(params.expected.status().code(),
+                         params.expected.status().message()));
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    DeriveTreeDataTests, DeriveTreeDataTest,
+    ::testing::Values(
+        TreeDataCase{
+            .name = "LeafCountTooLarge",
+            .leaf_count = 0x8000'0001,
+            .hashes_count = 3,
+            .auxiliary_count = 0,
+            .expected = absl::InvalidArgumentError(absl::StrFormat(
+                "Value is negative or too large: %d", 0x8000'0001)),
+        },
+        TreeDataCase{
+            .name = "HashesCountTooLarge",
+            .leaf_count = 5,
+            .hashes_count = 0x8000'0001,
+            .auxiliary_count = 0,
+            .expected = absl::InvalidArgumentError(absl::StrFormat(
+                "Value is negative or too large: %d", 0x8000'0001)),
+        },
+        TreeDataCase{
+            .name = "HashesBelowLeafRow",
+            .leaf_count = 3,
+            .hashes_count = 5,
+            .auxiliary_count = 0,
+            .expected = absl::InvalidArgumentError(
+                "hashes row size is larger than the leaf row size"),
+        },
+        TreeDataCase{
+            .name = "IncorrectHashesCountSameRow",
+            .leaf_count = 5,
+            .hashes_count = 6,
+            .auxiliary_count = 0,
+            .expected = absl::InvalidArgumentError(
+                "hashes count is not equal to the expected hashes count: "
+                "Hashes Count: 6, Leaf Count: 5, Expected Hashes Count: 5"),
+        },
+        TreeDataCase{
+            .name = "IncorrectHashesCountDifferentRows",
+            .leaf_count = 5,
+            .hashes_count = 4,
+            .auxiliary_count = 0,
+            .expected = absl::InvalidArgumentError(
+                "hashes count is not equal to the expected hashes count: "
+                "Hashes Count: 4, Leaf Count: 5, Expected Hashes Count: 3"),
+        },
+        TreeDataCase{
+            .name = "IncorrectAuxiliaryCountSameRows",
+            .leaf_count = 5,
+            .hashes_count = 5,
+            .auxiliary_count = 3,
+            .expected = absl::InvalidArgumentError(
+                "incorrect count of auxiliary data boxes: Auxiliary Data "
+                "Count: 3, Leaf Count: 5"),
+        },
+        TreeDataCase{
+            .name = "IncorrectAuxiliaryCountDifferentRows",
+            .leaf_count = 5,
+            .hashes_count = 3,
+            .auxiliary_count = 3,
+            .expected = absl::InvalidArgumentError(
+                "incorrect count of auxiliary data boxes: Auxiliary Data "
+                "Count: 3, Leaf Count: 5"),
+        },
+        TreeDataCase{
+            .name = "ValidTreeDataSameRowNoAuxiliary",
+            .leaf_count = 5,
+            .hashes_count = 5,
+            .auxiliary_count = 0,
+            .expected =
+                DerivedTreeData{
+                    .full_leaf_count = 8,
+                    .full_hashes_count = 8,
+                    .delta_rows = 0,
+                },
+        },
+        TreeDataCase{
+            .name = "ValidTreeDataSameRowWithAuxiliary",
+            .leaf_count = 5,
+            .hashes_count = 5,
+            .auxiliary_count = 5,
+            .expected =
+                DerivedTreeData{
+                    .full_leaf_count = 8,
+                    .full_hashes_count = 8,
+                    .delta_rows = 0,
+                },
+        },
+        TreeDataCase{
+            .name = "ValidTreeDataDifferentRows",
+            .leaf_count = 5,
+            .hashes_count = 3,
+            .auxiliary_count = 5,
+            .expected =
+                DerivedTreeData{
+                    .full_leaf_count = 8,
+                    .full_hashes_count = 4,
+                    .delta_rows = 1,
+                },
+        }),
+    [](const testing::TestParamInfo<TreeDataCase>& info) {
+      return info.param.name;
+    });
+
+struct CreateWithOptionsCase {
+  std::string name;
+  std::string contents;
+  int64_t starting_offset;
+  int64_t length;
+  CreateMerkleTreeOptions options;
+  std::variant<MerkleTree, absl::Status> expected;
+};
+
+using CreateWithOptionsTest = testing::TestWithParam<CreateWithOptionsCase>;
+
+TEST_P(CreateWithOptionsTest, CreatesMerkleTree) {
+  const CreateWithOptionsCase& params = GetParam();
+
+  riegeli::StringReader<> input(params.contents);
+
+  JoiningHasherFactory factory;
+
+  if (std::holds_alternative<absl::Status>(params.expected)) {
+    EXPECT_THAT(
+        CreateMerkleTree(&factory, input, params.starting_offset, params.length,
+                         params.options),
+        StatusIs(std::get<absl::Status>(params.expected).code(),
+                 HasSubstr(std::get<absl::Status>(params.expected).message())));
+  } else {
+    EXPECT_THAT(CreateMerkleTree(&factory, input, params.starting_offset,
+                                 params.length, params.options),
+                IsOkAndHolds(Eq(std::get<MerkleTree>(params.expected))));
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    OptionBasedTests, CreateWithOptionsTest,
+    ::testing::Values(
+        CreateWithOptionsCase{.name = "EmptyOptions",
+                              .contents = "abcde",
+                              .starting_offset = 0,
+                              .length = 5,
+                              .options = {},
+                              .expected =
+                                  MerkleTree{
+                                      {"abcde"},
+                                  }},
+        CreateWithOptionsCase{.name = "FixedBlockSizeSingleByte",
+                              .contents = "abcde",
+                              .starting_offset = 0,
+                              .length = 5,
+                              .options = {.fixed_block_size = 1},
+                              .expected =
+                                  MerkleTree{
+                                      {"a", "b", "c", "d", "e", "", "", ""},
+                                      {"ab", "cd", "e", ""},
+                                      {"abcd", "e"},
+                                      {"abcde"},
+                                  }},
+        CreateWithOptionsCase{.name = "FixedBlockSizeTwoBytes",
+                              .contents = "abcde",
+                              .starting_offset = 0,
+                              .length = 5,
+                              .options = {.fixed_block_size = 2},
+                              .expected =
+                                  MerkleTree{
+                                      {"ab", "cd", "e", ""},
+                                      {"abcd", "e"},
+                                      {"abcde"},
+                                  }},
+        CreateWithOptionsCase{.name = "VariableBlockSizes",
+                              .contents = "abcde",
+                              .starting_offset = 0,
+                              .length = 5,
+                              .options = {.variable_block_sizes = {3, 1, 1}},
+                              .expected =
+                                  MerkleTree{
+                                      {"abc", "d", "e", ""},
+                                      {"abcd", "e"},
+                                      {"abcde"},
+                                  }},
+        CreateWithOptionsCase{
+            .name = "FailsWithBothFixedAndVariableBlockSizes",
+            .contents = "abcde",
+            .starting_offset = 0,
+            .length = 5,
+            .options = {.fixed_block_size = 1,
+                        .variable_block_sizes = {3, 1, 1}},
+            .expected = absl::InvalidArgumentError(
+                "Must specify a fixed block size or "
+                "variable block sizes, not both."),
+        },
+        CreateWithOptionsCase{
+            .name = "FailsWithZeroFixedBlockSize",
+            .contents = "abcde",
+            .starting_offset = 0,
+            .length = 5,
+            .options = {.fixed_block_size = 0},
+            .expected = absl::InvalidArgumentError(
+                "Fixed block size must be greater than 0. Found: 0"),
+        },
+        CreateWithOptionsCase{
+            .name = "FailsWithInputTooSmall",
+            .contents = "abcde",
+            .starting_offset = 3,
+            .length = 5,
+            .options = {.fixed_block_size = 10},
+            .expected = absl::DataLossError("Failed to read leaf data"),
+        },
+        CreateWithOptionsCase{
+            .name = "FixedBlockSizeLargerThanInput",
+            .contents = "abcde",
+            .starting_offset = 0,
+            .length = 5,
+            .options = {.fixed_block_size = 10},
+            .expected = MerkleTree{{"abcde"}},
+        },
+        CreateWithOptionsCase{
+            .name = "FailsWithVariableBlockSizesTooLarge",
+            .contents = "abcde",
+            .starting_offset = 0,
+            .length = 5,
+            .options = {.variable_block_sizes = {2, 3, 5}},
+            .expected = absl::InvalidArgumentError(
+                "Total block size is too large. "
+                "Total Block size: 10, Length: 5"),
+        }),
+    [](const testing::TestParamInfo<CreateWithOptionsCase>& info) {
+      return info.param.name;
+    });
+
+}  // namespace
+}  // namespace credentio
diff --git a/bindings/merkle_validator.cc b/bindings/merkle_validator.cc
new file mode 100644
index 0000000..9ec8efe
--- /dev/null
+++ b/bindings/merkle_validator.cc
@@ -0,0 +1,288 @@
+// 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 <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") {
+            // 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) {
+              return absl::InvalidArgumentError(
+                  "box size too small for metadata headers");
+            }
+            int64_t remaining_data =
+                header.box_size - header.header_size - metadata_header_size;
+            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();
+  }
+
+  if (mdat_atom.box_size < mdat_atom.header_size) {
+    tracker.RecordFailure(
+        FailureStatusCode::kAssertionBmffHashMalformed,
+        {.url = assertion_uri_,
+         .explanation = "mdat atom box_size is smaller than header_size"});
+    return absl::InvalidArgumentError(
+        "mdat atom box_size is smaller than header_size");
+  }
+
+  // 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);
+  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.
+  int64_t offset = mdat_atom.start + mdat_atom.header_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());
+
+    if (!auxiliary_merkle_maps.empty()) {
+      int64_t running_row_index = i;
+      BmffMerkleMap auxiliary = auxiliary_merkle_maps[i];
+      for (const auto& hash : auxiliary.hashes()) {
+        if (running_row_index % 2 == 1) {
+          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;
+      }
+    }
+
+    int64_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)});
+      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
diff --git a/bindings/merkle_validator.h b/bindings/merkle_validator.h
new file mode 100644
index 0000000..3a103b4
--- /dev/null
+++ b/bindings/merkle_validator.h
@@ -0,0 +1,101 @@
+// 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_BINDINGS_MERKLE_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_VALIDATOR_H_
+
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "formats/bmff/box_header.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/status_tracker.h"
+
+namespace credentio {
+
+class HasherFactoryProvider {
+ public:
+  virtual ~HasherFactoryProvider() = default;
+  virtual absl::StatusOr<std::unique_ptr<HasherFactory> absl_nonnull> Create(
+      absl::string_view algo) const = 0;
+};
+
+class DefaultHasherFactoryProvider : public HasherFactoryProvider {
+ public:
+  absl::StatusOr<std::unique_ptr<HasherFactory> absl_nonnull> Create(
+      absl::string_view algo) const override {
+    ABSL_ASSIGN_OR_RETURN(auto algorithm, ParseHashAlgorithm(algo));
+    switch (algorithm) {
+      case HashAlgorithm::kSha256:
+        return std::make_unique<Sha256HasherFactory>();
+      case HashAlgorithm::kSha384:
+        return std::make_unique<Sha384HasherFactory>();
+      case HashAlgorithm::kSha512:
+        return std::make_unique<Sha512HasherFactory>();
+    }
+    return nullptr;
+  }
+};
+
+class MerkleValidator {
+ public:
+  // Creates a MerkleValidator that will validate the merkle-maps contained
+  // within the BmffBasedHashAssertion. The validator will use the provided
+  // factory to create hashers for each hash algorithm it encounters.
+  MerkleValidator(
+      riegeli::Reader& contents, const BmffBasedHashAssertion& assertion,
+      absl::string_view assertion_uri,
+      std::unique_ptr<HasherFactoryProvider> absl_nonnull factory_provider =
+          std::make_unique<DefaultHasherFactoryProvider>())
+      : contents_(contents),
+        factory_provider_(std::move(factory_provider)),
+        assertion_(assertion),
+        assertion_uri_(assertion_uri) {}
+
+  ~MerkleValidator() = default;
+
+  // Validates all the merkle-maps contained within the BmffBasedHashAssertion,
+  // recording any failures in the StatusTracker. This method will not add any
+  // success codes to the tracker, as that is handled by the main
+  // BmffHashHardBindingValidator.
+  absl::Status Validate(StatusTracker& tracker) const;
+
+ private:
+  riegeli::Reader& contents_;
+  std::unique_ptr<HasherFactoryProvider> factory_provider_;
+  const BmffBasedHashAssertion& assertion_;
+  absl::string_view assertion_uri_;
+
+  // Validates a single merkle map, recording any failures in the
+  // StatusTracker.
+  absl::Status ValidateMerkleMap(
+      const BmffMerkle& merkle, const BmffBoxHeader& mdat_atom,
+      std::vector<BmffMerkleMap> auxiliary_merkle_maps,
+      absl::string_view fallback_algo, StatusTracker& tracker) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_MERKLE_VALIDATOR_H_
diff --git a/bindings/merkle_validator_test.cc b/bindings/merkle_validator_test.cc
new file mode 100644
index 0000000..993575b
--- /dev/null
+++ b/bindings/merkle_validator_test.cc
@@ -0,0 +1,624 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "bindings/merkle_validator.h"
+
+#include <algorithm>
+#include <cctype>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+#include "formats/bmff/test_utils.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/cbor_utils.h"
+#include "testing/proto_test_utils.h"
+#include "utils/status_tracker.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAreArray;
+using ::testing::IsEmpty;
+
+class TrackingStatusTracker : public StatusTracker {
+ public:
+  TrackingStatusTracker() = default;
+  ~TrackingStatusTracker() override = default;
+
+  struct SuccessRecord {
+    SuccessStatusCode code;
+    std::string url = "";
+    std::string explanation = "";
+
+    bool operator==(const SuccessRecord& other) const {
+      return code == other.code && url == other.url &&
+             explanation == other.explanation;
+    }
+  };
+
+  struct FailureRecord {
+    FailureStatusCode code;
+    std::string url = "";
+    std::string explanation = "";
+
+    bool operator==(const FailureRecord& other) const {
+      return code == other.code && url == other.url &&
+             explanation == other.explanation;
+    }
+  };
+
+  struct InformationalRecord {
+    InformationalStatusCode code;
+    std::string url = "";
+    std::string explanation = "";
+
+    bool operator==(const InformationalRecord& other) const {
+      return code == other.code && url == other.url &&
+             explanation == other.explanation;
+    }
+  };
+
+  void RecordSuccess(SuccessStatusCode code,
+                     ValidationTracker::RecordOptions options) override {
+    successes_.push_back(SuccessRecord{
+        .code = code,
+        .url = std::string(options.url),
+        .explanation = std::string(options.explanation),
+    });
+  };
+  void RecordFailure(FailureStatusCode code,
+                     ValidationTracker::RecordOptions options) override {
+    failures_.push_back(FailureRecord{
+        .code = code,
+        .url = std::string(options.url),
+        .explanation = std::string(options.explanation),
+    });
+  };
+  void RecordInformational(InformationalStatusCode code,
+                           ValidationTracker::RecordOptions options) override {
+    informationals_.push_back(InformationalRecord{
+        .code = code,
+        .url = std::string(options.url),
+        .explanation = std::string(options.explanation),
+    });
+  };
+
+  std::vector<SuccessRecord> successes_;
+  std::vector<FailureRecord> failures_;
+  std::vector<InformationalRecord> informationals_;
+};
+
+class JoiningHasherFactoryProvider : public HasherFactoryProvider {
+ public:
+  class JoiningHasherFactory : public HasherFactory {
+   public:
+    class JoiningHasher : public Hasher {
+     public:
+      void Update(absl::string_view content) override {
+        digest_ = absl::StrCat(digest_, content);
+
+        std::replace_if(
+            digest_.begin(), digest_.end(),
+            [](char c) { return !std::isalnum(static_cast<unsigned char>(c)); },
+            '_');
+      }
+
+      std::string Digest() override { return digest_; }
+
+     protected:
+      std::string digest_ = "";
+    };
+
+    absl::StatusOr<std::unique_ptr<Hasher>> Create() const override {
+      return std::make_unique<JoiningHasher>();
+    };
+    HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; };
+  };
+
+  absl::StatusOr<std::unique_ptr<HasherFactory>> Create(
+      absl::string_view algo) const override {
+    if (algo == "error") {
+      return absl::InvalidArgumentError(
+          absl::StrFormat("unsupported hash algorithm: %s", algo));
+    }
+    return std::make_unique<JoiningHasherFactory>();
+  }
+};
+
+struct MerkleValidatorTestCase {
+  std::string name;
+  std::string file_contents;
+  BmffBasedHashAssertion assertion;
+  absl::Status expected_status;
+  std::vector<TrackingStatusTracker::FailureRecord> expected_failures;
+};
+
+class MerkleValidatorTest
+    : public ::testing::TestWithParam<MerkleValidatorTestCase> {
+ public:
+  void SetUp() override {
+    auto input_reader =
+        std::make_unique<riegeli::StringReader<>>(GetParam().file_contents);
+    ASSERT_THAT(input_reader->status(), IsOk());
+    input_ = std::move(input_reader);
+
+    factory_ = std::make_unique<JoiningHasherFactoryProvider>();
+    assertion_uri_ = "assertion_uri";
+
+    validator_ = std::make_unique<MerkleValidator>(
+        *input_, GetParam().assertion, assertion_uri_,
+        std::make_unique<JoiningHasherFactoryProvider>());
+  }
+
+  std::unique_ptr<riegeli::Reader> input_;
+  std::unique_ptr<HasherFactoryProvider> factory_;
+  std::string assertion_uri_;
+  std::unique_ptr<MerkleValidator> validator_;
+};
+
+TEST_P(MerkleValidatorTest, Validate) {
+  TrackingStatusTracker tracker;
+
+  EXPECT_THAT(validator_->Validate(tracker),
+              StatusIs(GetParam().expected_status.code(),
+                       GetParam().expected_status.message()));
+
+  // Successes are not marked within the MerkleValidator.
+  EXPECT_THAT(tracker.successes_, IsEmpty());
+  EXPECT_THAT(tracker.failures_,
+              ElementsAreArray(GetParam().expected_failures));
+  EXPECT_THAT(tracker.informationals_, IsEmpty());
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    MerkleValidatorTests, MerkleValidatorTest,
+    ::testing::Values(
+        MerkleValidatorTestCase{
+            .name = "FailsWithInvalidInput",
+            .file_contents = "this_is_not_a_valid_bmff_file",
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles { count: 1 fixed_block_size: 10 }
+            )pb"),
+            .expected_status =
+                absl::InternalError("failed to extract leaf data"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kGoogleInternalError,
+                  .url = "assertion_uri",
+                  .explanation = "truncated BMFF box; at byte 8"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsMdatCountNotMatching",
+            .file_contents =
+                absl::StrCat(credentio_testing::Box("mdat", "data_one"),
+                             credentio_testing::Box("mdat", "data_two")),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles { count: 1 fixed_block_size: 10 }
+            )pb"),
+            .expected_status = absl::InternalError(
+                "number of mdat atoms does not match the "
+                "number of merkle maps"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kAssertionBmffHashMalformed,
+                  .url = "assertion_uri",
+                  .explanation = "number of mdat atoms does not match "
+                                 "the number of merkle maps"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsFragmentedUnsupported",
+            .file_contents = credentio_testing::Box("mdat", "abcdef"),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                count: 2
+                init_hash: "only_for_fragmented"
+                fixed_block_size: 3
+              }
+            )pb"),
+            .expected_status = absl::UnimplementedError(
+                "fragmented merkle validations are not supported yet"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kGoogleInternalError,
+                  .url = "assertion_uri",
+                  .explanation =
+                      "fragmented merkle validations are not supported yet"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsHashesBelowLeafs",
+            .file_contents = credentio_testing::Box("mdat", "abcdef"),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                count: 3
+                fixed_block_size: 10
+                hashes: "a"
+                hashes: "b"
+                hashes: "c"
+                hashes: "d"
+                hashes: "e"
+                hashes: "f"
+              }
+            )pb"),
+            .expected_status = absl::InvalidArgumentError(
+                "hashes row size is larger than the leaf row size"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kAssertionBmffHashMalformed,
+                  .url = "assertion_uri",
+                  .explanation =
+                      "hashes row size is larger than the leaf row size"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsLeafSizesTooLarge",
+            .file_contents = credentio_testing::Box("mdat", "abcdef"),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles { count: 1 variable_block_sizes: 10 hashes: "abcdef" }
+            )pb"),
+            .expected_status = absl::InvalidArgumentError(
+                "merkle map's variable block sizes do "
+                "not sum to the mdat box size"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kAssertionBmffHashMalformed,
+                  .url = "assertion_uri",
+                  .explanation = "merkle map's variable block sizes do not sum "
+                                 "to the mdat box size"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsLeafHashMismatch",
+            .file_contents = credentio_testing::Box("mdat", "abcdef"),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                count: 3
+                fixed_block_size: 2
+                hashes: "wrong_hash"
+                hashes: "cd"
+                hashes: "ef"
+              }
+            )pb"),
+            .expected_status = absl::InternalError("merkle map hash mismatch"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kAssertionBmffHashMismatch,
+                  .url = "assertion_uri",
+                  .explanation = "merkle map hash mismatch at index 0: "
+                                 "Expected: wrong_hash, Actual: ab"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsAuxiliaryDataBeforeLastMdat",
+            .file_contents = []() -> std::string {
+              std::string aux_box = cbor::FromJson(R"json({
+  "hashes": [ "b64'Zm9v'" ],
+  "localId": 1,
+  "location": 0,
+  "uniqueId": 1
+})json");
+              return absl::StrCat(credentio_testing::Box(
+                                      "uuid", credentio_testing::C2paBoxPayload(
+                                                  "merkle", aux_box)),
+                                  credentio_testing::Box("mdat", "abcdef"));
+            }(),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                count: 7
+                fixed_block_size: 2
+                hashes: "wrong_hash"
+                hashes: "cd"
+                hashes: "ef"
+              }
+            )pb"),
+            .expected_status =
+                absl::InternalError("failed to extract leaf data"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kGoogleInternalError,
+                  .url = "assertion_uri",
+                  .explanation =
+                      "encountered auxiliary atom before the first mdat atom"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsIncorrectAuxiliaryCount",
+            .file_contents = credentio_testing::Box("mdat", "abcdef"),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                count: 3
+                fixed_block_size: 2
+                hashes: "wrong_hash"
+                hashes: "cd"
+                hashes: "ef"
+              }
+            )pb"),
+            .expected_status = absl::InternalError("merkle map hash mismatch"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kAssertionBmffHashMismatch,
+                  .url = "assertion_uri",
+                  .explanation = "merkle map hash mismatch at index 0: "
+                                 "Expected: wrong_hash, Actual: ab"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsUnsupportedHashAlgorithm",
+            .file_contents = credentio_testing::Box("mdat", "abcdef"),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "error"
+              hash: "unused"
+              merkles {
+                count: 3
+                fixed_block_size: 2
+                hashes: "ab"
+                hashes: "cd"
+                hashes: "ef"
+              }
+            )pb"),
+            .expected_status =
+                absl::InvalidArgumentError("unsupported hash algorithm: error"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kAssertionBmffHashMalformed,
+                  .url = "assertion_uri",
+                  .explanation = "unsupported hash algorithm: error"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsInvalidHashWithAuxiliaryData",
+            .file_contents = []() -> std::string {
+              std::string aux_box_1 = cbor::FromJson(R"json({
+  "hashes": [ "b64'ZGVm'" ],
+  "localId": 1,
+  "location": 0,
+  "uniqueId": 1
+})json");
+              std::string aux_box_2 = cbor::FromJson(R"json({
+  "hashes": [ "b64'YWJj'" ],
+  "localId": 1,
+  "location": 1,
+  "uniqueId": 1
+})json");
+              return absl::StrCat(credentio_testing::Box("mdat", "abcdef"),
+                                  credentio_testing::Box(
+                                      "uuid", credentio_testing::C2paBoxPayload(
+                                                  "merkle", aux_box_1)),
+                                  credentio_testing::Box(
+                                      "uuid", credentio_testing::C2paBoxPayload(
+                                                  "merkle", aux_box_2)));
+            }(),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                unique_id: 1
+                local_id: 1
+                count: 2
+                fixed_block_size: 3
+                hashes: "wrong_hash"
+              }
+            )pb"),
+            .expected_status = absl::InternalError("merkle map hash mismatch"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kAssertionBmffHashMismatch,
+                  .url = "assertion_uri",
+                  .explanation = "merkle map hash mismatch at index 0: "
+                                 "Expected: wrong_hash, Actual: abcdef"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsBoxSizeTooSmallForMetadataHeaders",
+            .file_contents = []() -> std::string {
+              std::string aux_box_1 = cbor::FromJson(R"json({
+                  "hashes": [ "b64'ZGVm'" ],
+                  "localId": 1,
+                  "location": 0,
+                  "uniqueId": 1
+                })json");
+              return absl::StrCat(
+                  credentio_testing::Box("mdat", "abcdef"),
+                  credentio_testing::BadBox(
+                      "uuid",
+                      credentio_testing::C2paBoxPayload("merkle", aux_box_1),
+                      28));
+            }(),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                unique_id: 1
+                local_id: 1
+                count: 2
+                fixed_block_size: 3
+                hashes: "wrong_hash"
+              }
+            )pb"),
+            .expected_status =
+                absl::InternalError("failed to extract leaf data"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kGoogleInternalError,
+                  .url = "assertion_uri",
+                  .explanation = "box size too small for metadata headers"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsMdatBoxSizeSmallerThanHeaderSize",
+            .file_contents = std::string(
+                "\x00\x00\x00\x01mdat\x00\x00\x00\x00\x00\x00\x00\x08", 16),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles { count: 1 fixed_block_size: 2 hashes: "11" }
+            )pb"),
+            .expected_status =
+                absl::InternalError("failed to extract leaf data"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kGoogleInternalError,
+                  .url = "assertion_uri",
+                  .explanation = "kInvalidData; box size too small for header; "
+                                 "at byte 16"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "FailsTruncatedMerkleCrashPrevention",
+            .file_contents = credentio_testing::Box("mdat",
+                                                    std::string(28, 'a')),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                count: 14
+                fixed_block_size: 2
+                hashes: "11"
+                hashes: "22"
+                hashes: "33"
+              }
+            )pb"),
+            .expected_status = absl::InvalidArgumentError(
+                "hashes count is not equal to the expected hashes count: "
+                "Hashes Count: 3, Leaf Count: 14, Expected Hashes Count: 4"),
+            .expected_failures =
+                {{.code = FailureStatusCode::kAssertionBmffHashMalformed,
+                  .url = "assertion_uri",
+                  .explanation =
+                      "hashes count is not equal to the expected hashes count: "
+                      "Hashes Count: 3, Leaf Count: 14, Expected Hashes Count: "
+                      "4"}},
+        },
+        MerkleValidatorTestCase{
+            .name = "SuccessFixedBlockSizeWithHashesAsLeafRow",
+            .file_contents = credentio_testing::Box("mdat", "abcdef"),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                count: 3
+                fixed_block_size: 2
+                hashes: "ab"
+                hashes: "cd"
+                hashes: "ef"
+              }
+            )pb"),
+            .expected_status = absl::OkStatus(),
+            .expected_failures = {},
+        },
+        MerkleValidatorTestCase{
+            .name = "SuccessVariableBlockSizeWithHashesAsLeafRow",
+            .file_contents = credentio_testing::Box("mdat", "abcdef"),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                count: 2
+                variable_block_sizes: 4
+                variable_block_sizes: 2
+                hashes: "abcd"
+                hashes: "ef"
+              }
+            )pb"),
+            .expected_status = absl::OkStatus(),
+            .expected_failures = {},
+        },
+        MerkleValidatorTestCase{
+            .name = "SuccessHashWithAuxiliaryData",
+            .file_contents = []() -> std::string {
+              std::string aux_box_1 = cbor::FromJson(R"json({
+  "hashes": [ "b64'ZGVm'" ],
+  "localId": 1,
+  "location": 0,
+  "uniqueId": 1
+})json");
+              std::string aux_box_2 = cbor::FromJson(R"json({
+  "hashes": [ "b64'YWJj'" ],
+  "localId": 1,
+  "location": 1,
+  "uniqueId": 1
+})json");
+              return absl::StrCat(credentio_testing::Box("mdat", "abcdef"),
+                                  credentio_testing::Box(
+                                      "uuid", credentio_testing::C2paBoxPayload(
+                                                  "merkle", aux_box_1)),
+                                  credentio_testing::Box(
+                                      "uuid", credentio_testing::C2paBoxPayload(
+                                                  "merkle", aux_box_2)));
+            }(),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                unique_id: 1
+                local_id: 1
+                count: 2
+                fixed_block_size: 3
+                hashes: "abcdef"
+              }
+            )pb"),
+            .expected_status = absl::OkStatus(),
+            .expected_failures = {}}),
+    [](const testing::TestParamInfo<MerkleValidatorTestCase>& info) {
+      return info.param.name;
+    });
+
+using ::testing::NotNull;
+
+TEST(DefaultHasherFactoryProviderTest, CreateSha256) {
+  DefaultHasherFactoryProvider provider;
+  auto factory = provider.Create("sha256");
+  ASSERT_THAT(factory, IsOk());
+  EXPECT_THAT(*factory, NotNull());
+  EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha256);
+}
+
+TEST(DefaultHasherFactoryProviderTest, CreateSha384) {
+  DefaultHasherFactoryProvider provider;
+  auto factory = provider.Create("sha384");
+  ASSERT_THAT(factory, IsOk());
+  EXPECT_THAT(*factory, NotNull());
+  EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha384);
+}
+
+TEST(DefaultHasherFactoryProviderTest, CreateSha512) {
+  DefaultHasherFactoryProvider provider;
+  auto factory = provider.Create("sha512");
+  ASSERT_THAT(factory, IsOk());
+  EXPECT_THAT(*factory, NotNull());
+  EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha512);
+}
+
+TEST(DefaultHasherFactoryProviderTest, CreateInvalid) {
+  DefaultHasherFactoryProvider provider;
+  EXPECT_THAT(provider.Create("invalid"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/bindings/validator.cc b/bindings/validator.cc
new file mode 100644
index 0000000..9478d11
--- /dev/null
+++ b/bindings/validator.cc
@@ -0,0 +1,668 @@
+// 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/validator.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <iterator>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <variant>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/functional/function_ref.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "bindings/bmff_hash_hard_binding_validator.h"
+#include "bindings/boxes_hash_hard_binding_validator.h"
+#include "bindings/collection_data_hash_hard_binding_validator.h"
+#include "bindings/data_hash_hard_binding_validator.h"
+#include "constants/status_codes.h"
+#include "formats/asset_box.h"
+#include "formats/bmff/assessor.h"
+#include "formats/bmff/box_header.h"
+#include "formats/byte_range.h"
+#include "formats/format.h"
+#include "formats/registry.h"
+#include "jumbf/uri.h"
+#include "proto/assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/dual_status_tracker.h"
+#include "utils/status_tracker.h"
+#include "utils/two_stage_status_tracker.h"
+#include "utils/uri.h"
+#include "validator/result.h"
+#include "validator/validation_result_internal.h"
+
+namespace credentio {
+
+namespace {
+
+struct OffsetLocator {
+  int64_t offset;
+  int64_t length;
+};
+
+struct BmffLocator {
+  std::string path;
+};
+
+absl::StatusOr<std::variant<OffsetLocator, BmffLocator>> GetLocator(
+    const MultiAssetPartLocation& part) {
+  if (!part.has_byte_offset() && !part.has_length() && !part.has_bmff_box()) {
+    return absl::InvalidArgumentError("no location specified");
+  }
+  if (part.has_bmff_box() && (part.has_byte_offset() || part.has_length())) {
+    return absl::InvalidArgumentError(
+        "bmff box cannot be used with byte range locator");
+  }
+  if (part.has_bmff_box()) {
+    return BmffLocator{.path = std::string(part.bmff_box())};
+  }
+  if (!part.has_byte_offset() || !part.has_length()) {
+    return absl::InvalidArgumentError(
+        "byte range locator must have both offset and length");
+  }
+  return OffsetLocator{.offset = part.byte_offset(), .length = part.length()};
+}
+
+std::unique_ptr<PartialValidationResultProto> CopyManifestLabels(
+    const PartialValidationResultProto& source) {
+  auto target = std::make_unique<PartialValidationResultProto>();
+  target->set_hard_binding_uri(source.hard_binding_uri());
+  target->set_multi_asset_hash_uri(source.multi_asset_hash_uri());
+
+  target->mutable_active_manifest()->set_label(
+      source.active_manifest().label());
+  for (const Manifest& manifest : source.ingredient_manifests()) {
+    target->add_ingredient_manifests()->set_label(manifest.label());
+  }
+  return target;
+}
+
+absl::StatusOr<std::unique_ptr<ValidationResultProto>>
+CompleteValidationResults(
+    std::unique_ptr<PartialValidationResultProto> target,
+    std::unique_ptr<PartialValidationResultProto> source) {
+  target->mutable_active_manifest()->mutable_validation()->MergeFrom(
+      std::move(*source->mutable_active_manifest()->mutable_validation()));
+
+  absl::flat_hash_map<absl::string_view, ValidationStatusSet> source_results;
+  for (const Manifest& source_manifest : source->ingredient_manifests()) {
+    source_results[source_manifest.label()] =
+        std::move(source_manifest.validation());
+  }
+  for (Manifest& target_manifest : *target->mutable_ingredient_manifests()) {
+    if (source_results.contains(target_manifest.label())) {
+      target_manifest.mutable_validation()->MergeFrom(
+          std::move(source_results[target_manifest.label()]));
+    }
+  }
+  return MakeFullValidationResult(std::move(target));
+}
+
+// Validates the parts of a multi-asset hash assertion. This ensures that the
+// parts do not overlap and that the total size of the required parts does not
+// exceed the size of the asset. Only problems accessing the files return as
+// non-OK status, other errors are recorded in the status tracker.
+absl::Status ValidateParts(const MultiAssetHashAssertion& assertion,
+                           absl::string_view assertion_uri, int64_t asset_size,
+                           StatusTracker& tracker) {
+  int64_t next_expected_offset = 0;
+  int64_t minimum_required_size = 0;
+  for (const auto& part : assertion.parts()) {
+    absl::StatusOr<std::variant<OffsetLocator, BmffLocator>> locator =
+        GetLocator(part.location());
+    if (!locator.ok()) {
+      tracker.RecordFailure(
+          FailureStatusCode::kAssertionMultiAssetHashMalformed,
+          {.url = assertion_uri});
+      return absl::OkStatus();
+    }
+    if (std::holds_alternative<BmffLocator>(*locator)) {
+      // Contains a BMFF locator, cannot ensure bytes included based on offsets.
+      return absl::OkStatus();
+    }
+    OffsetLocator location = std::get<OffsetLocator>(*locator);
+    if (location.offset != next_expected_offset) {
+      tracker.RecordFailure(
+          FailureStatusCode::kAssertionMultiAssetHashMalformed,
+          {.url = assertion_uri});
+      return absl::OkStatus();
+    }
+    next_expected_offset = location.offset + location.length;
+    if (!part.is_optional()) {
+      minimum_required_size += location.length;
+    }
+  }
+  if (minimum_required_size > asset_size) {
+    tracker.RecordFailure(
+        FailureStatusCode::kAssertionMultiAssetHashMissingPart,
+        {.url = assertion_uri});
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<ContentBindingValidator::Structure>
+ContentBindingValidator::ExtractAssetStructure(riegeli::Reader& contents,
+                                               const Format* format,
+                                               int64_t end_offset) const {
+  ContentBindingValidator::Structure structure{
+      .asset = ByteRange{.offset = contents.pos(),
+                         .length = end_offset - contents.pos()}};
+
+  absl::StatusOr<const Format*> inferred_format;
+  if (format == nullptr) {
+    inferred_format = format_registry_->GetFormat(contents);
+    if (!inferred_format.ok()) {
+      if (structure.asset.offset == 0) {
+        // Invalid format on the first asset therefore unsupported format.
+        return inferred_format.status();
+      }
+      return structure;
+    }
+    format = *inferred_format;
+  }
+
+  absl::StatusOr<std::vector<AssetBox>> boxes =
+      format->extractor()->ExtractBoxes(contents, {.requires_c2pa = false});
+  if (boxes.ok() && !boxes->empty()) {
+    ABSL_ASSIGN_OR_RETURN(
+        int64_t end_of_asset_offset,
+        PopulateStructureFromBoxes(*std::move(boxes), structure));
+
+    // Only assets which contain boxes can support multiple assets (by finding
+    // the c2pa.after box). Navigate to the start of the next potential asset
+    // and continue to extract the structure.
+    if (!contents.Seek(end_of_asset_offset) ||
+        contents.pos() != end_of_asset_offset) {
+      return contents.StatusOrAnnotate(
+          absl::InternalError("failed to seek to end of asset"));
+    }
+    return structure;
+  }
+
+  // The determined format does not support multiple assets at this point,
+  // attempt to find the manifest store location and treat the remainder of
+  // the file as the last asset.
+  absl::StatusOr<std::optional<ByteRange>> manifest_store_location =
+      format->extractor()->ExtractManifestStoreLocation(
+          contents, {.requires_c2pa = false, .end_offset = end_offset});
+  if (manifest_store_location.ok() && manifest_store_location->has_value()) {
+    structure.manifest_store_location = *std::move(manifest_store_location);
+  }
+
+  return structure;
+}
+
+absl::StatusOr<int64_t> ContentBindingValidator::PopulateStructureFromBoxes(
+    std::vector<AssetBox> boxes,
+    ContentBindingValidator::Structure& structure) const {
+  if (boxes.empty()) {
+    return absl::InvalidArgumentError("no boxes found");
+  }
+
+  structure.boxes = std::move(boxes);
+
+  const AssetBox* last_box = nullptr;
+  for (const AssetBox& box : structure.boxes) {
+    last_box = &box;
+
+    if (box.identifier == "C2PA") {
+      structure.manifest_store_location = ByteRange{
+          .offset = box.byte_range.offset, .length = box.byte_range.length};
+    }
+  }
+
+  if (last_box != nullptr && last_box->identifier == "c2pa.after") {
+    structure.asset.length =
+        last_box->byte_range.offset - structure.asset.offset;
+  }
+
+  return structure.asset.offset + structure.asset.length;
+}
+
+absl::StatusOr<std::vector<ContentBindingValidator::Structure>>
+ContentBindingValidator::GetStructure(riegeli::Reader& contents,
+                                      const Format& format) const {
+  std::vector<ContentBindingValidator::Structure> structures;
+
+  if (!contents.SupportsSize() || !contents.Size().has_value()) {
+    return absl::InvalidArgumentError("asset does not support size");
+  }
+
+  if (contents.Size() == 0) {
+    return absl::InvalidArgumentError("asset has no contents");
+  }
+
+  if (!contents.Seek(0) || contents.pos() != 0) {
+    return contents.StatusOrAnnotate(
+        absl::InternalError("failed to seek to start of asset"));
+  }
+
+  uint64_t end_offset = *contents.Size();
+  bool at_end_of_file = false;
+  while (!at_end_of_file) {
+    // Format is always inferred for non-first parts.
+    const Format* part_format = structures.empty() ? &format : nullptr;
+    ABSL_ASSIGN_OR_RETURN(
+        ContentBindingValidator::Structure structure,
+        ExtractAssetStructure(contents, part_format, end_offset));
+
+    at_end_of_file =
+        (structure.asset.offset + structure.asset.length) == end_offset;
+
+    structures.push_back(std::move(structure));
+  }
+
+  return structures;
+}
+
+absl::StatusOr<std::unique_ptr<ValidationResultProto>>
+ContentBindingValidator::Validate(riegeli::Reader& contents,
+                                  const Format& format,
+                                  std::unique_ptr<PartialValidationResultProto>
+                                      partial_validation_result) const {
+  std::unique_ptr<PartialValidationResultProto> result =
+      CopyManifestLabels(*partial_validation_result);
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<DualStatusTracker> tracker,
+      DualStatusTracker::FromPartialValidationResult(&*result));
+  bool assertion_in_ingredient_manifest = tracker->WritesToIngredientManifest();
+  TwoStageStatusTracker staging_tracker(*tracker);
+
+  ABSL_ASSIGN_OR_RETURN(
+      std::vector<ContentBindingValidator::Structure> asset_structure,
+      GetStructure(contents, format));
+  auto assertion_fetcher = [&](absl::string_view uri) {
+    return GetAssertion(partial_validation_result.get(), uri);
+  };
+  // Getting the structure causes the Tell to be at the end of the file, reset.
+  if (!contents.Seek(0) || contents.pos() != 0) {
+    return contents.StatusOrAnnotate(
+        absl::InternalError("failed to seek to start of asset"));
+  }
+  ABSL_RETURN_IF_ERROR(ValidateAssertion(
+      contents, assertion_fetcher,
+      partial_validation_result->hard_binding_uri(), asset_structure,
+      /*requires_c2pa=*/true, staging_tracker,
+      assertion_in_ingredient_manifest));
+
+  if (!staging_tracker.HasFailuresInStage1() ||
+      partial_validation_result->multi_asset_hash_uri().empty()) {
+    // Success or no multi-asset hard binding, finished.
+    staging_tracker.WriteStage1();
+    return CompleteValidationResults(std::move(partial_validation_result),
+                                     std::move(result));
+  }
+
+  staging_tracker.MoveStage1ToStage2();
+
+  // Validate the multi-asset hard binding assertion.
+  if (!contents.Seek(0) || contents.pos() != 0) {
+    return contents.StatusOrAnnotate(
+        absl::InternalError("failed to seek to start of asset"));
+  }
+  ABSL_RETURN_IF_ERROR(ValidateAssertion(
+      contents, assertion_fetcher,
+      partial_validation_result->multi_asset_hash_uri(), asset_structure,
+      /*requires_c2pa=*/true, staging_tracker,
+      assertion_in_ingredient_manifest));
+  if (staging_tracker.HasFailuresInStage1()) {
+    // Failed, add all the staged codes to the result.
+    staging_tracker.WriteStage2();
+  }
+  staging_tracker.WriteStage1();
+  return CompleteValidationResults(std::move(partial_validation_result),
+                                   std::move(result));
+}
+
+absl::Status ContentBindingValidator::ValidateAssertion(
+    riegeli::Reader& contents, AssertionFetcherRef assertion_fetcher,
+    absl::string_view assertion_uri,
+    std::vector<ContentBindingValidator::Structure> asset_structure,
+    bool requires_c2pa, TwoStageStatusTracker& tracker,
+    bool assertion_in_ingredient_manifest, int64_t end_offset) const {
+  const Assertion* absl_nullable assertion = assertion_fetcher(assertion_uri);
+  if (assertion == nullptr) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("missing assertion: ", assertion_uri));
+  }
+
+  switch (assertion->assertion_case()) {
+    case Assertion::kBoxesHash:
+      if (asset_structure.empty()) {
+        return absl::InvalidArgumentError(
+            "asset structure cannot be determined");
+      }
+      return ValidateBoxesHash(contents, assertion_uri, assertion->boxes_hash(),
+                               asset_structure, requires_c2pa, tracker,
+                               end_offset);
+    case Assertion::kDataHash:
+      return ValidateDataHash(
+          contents, assertion_uri, assertion->data_hash(),
+          asset_structure.empty()
+              ? std::nullopt
+              : asset_structure.front().manifest_store_location,
+          requires_c2pa, tracker, assertion_in_ingredient_manifest, end_offset);
+    case Assertion::kCollectionDataHash:
+      return ValidateCollectionDataHash(contents, assertion_uri,
+                                        assertion->collection_data_hash(),
+                                        requires_c2pa, tracker);
+    case Assertion::kBmffBasedHash:
+      return ValidateBmffHash(contents, assertion_uri,
+                              assertion->bmff_based_hash(), requires_c2pa,
+                              tracker);
+    case Assertion::kMultiAssetHash:
+      return ValidateMultiAssetHash(
+          contents, assertion_uri, assertion->multi_asset_hash(),
+          asset_structure, requires_c2pa, assertion_fetcher, tracker,
+          assertion_in_ingredient_manifest);
+    default:
+      return absl::InvalidArgumentError(
+          absl::StrCat("assertion is not a hard binding assertion: ",
+                       assertion->assertion_case()));
+  }
+}
+
+absl::Status ContentBindingValidator::ValidateDataHash(
+    riegeli::Reader& contents, absl::string_view assertion_uri,
+    const DataHashAssertion& assertion,
+    std::optional<ByteRange> manifest_store_location, bool requires_c2pa,
+    StatusTracker& tracker, bool assertion_in_ingredient_manifest,
+    int64_t end_offset) const {
+  DataHashHardBindingValidator().Validate(
+      contents, manifest_store_location, assertion, assertion_uri, tracker,
+      assertion_in_ingredient_manifest, contents.pos(), end_offset);
+  return absl::OkStatus();
+}
+
+absl::Status ContentBindingValidator::ValidateCollectionDataHash(
+    riegeli::Reader& contents, absl::string_view assertion_uri,
+    const CollectionDataHashAssertion& assertion, bool requires_c2pa,
+    StatusTracker& tracker) const {
+  CollectionDataHashHardBindingValidator().Validate(contents, assertion,
+                                                    assertion_uri, tracker);
+  return absl::OkStatus();
+}
+
+absl::Status ContentBindingValidator::ValidateBoxesHash(
+    riegeli::Reader& contents, absl::string_view assertion_uri,
+    const BoxesHashAssertion& assertion,
+    std::vector<ContentBindingValidator::Structure> remaining_structure,
+    bool requires_c2pa, StatusTracker& tracker, int64_t end_offset) const {
+  if (assertion.boxes().empty()) {
+    // No boxes in assertion.
+    tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                          {.url = assertion_uri});
+    return absl::OkStatus();
+  }
+  for (const auto& box : assertion.boxes()) {
+    if (box.names().empty()) {
+      // Boxes Assertion has no names.
+      tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMalformed,
+                            {.url = assertion_uri});
+      return absl::OkStatus();
+    }
+  }
+  bool assertion_has_c2pa_after =
+      *assertion.boxes().rbegin()->names().rbegin() == "c2pa.after";
+
+  if (remaining_structure.empty()) {
+    // No Asset Data.
+    tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch,
+                          {.url = assertion_uri});
+    return absl::OkStatus();
+  }
+  std::vector<AssetBox> asset_boxes = remaining_structure.front().boxes;
+  if (asset_boxes.empty()) {
+    // No boxes in asset.
+    tracker.RecordFailure(FailureStatusCode::kAssertionBoxesHashMismatch,
+                          {.url = assertion_uri});
+    return absl::OkStatus();
+  }
+  bool extraction_has_c2pa_after =
+      asset_boxes.back().identifier == "c2pa.after";
+
+  if (!assertion_has_c2pa_after && extraction_has_c2pa_after) {
+    int64_t part_idx = 1;
+    while (part_idx < remaining_structure.size() &&
+           !remaining_structure[part_idx].boxes.empty()) {
+      // Remove the c2pa.after box.
+      asset_boxes.pop_back();
+
+      // Add the boxes from the next asset.
+      Structure& next_asset = remaining_structure[part_idx];
+      asset_boxes.reserve(asset_boxes.size() + next_asset.boxes.size());
+      asset_boxes.insert(asset_boxes.end(), next_asset.boxes.begin(),
+                         next_asset.boxes.end());
+
+      // Increment the index to the next asset.
+      ++part_idx;
+    }
+  }
+
+  BoxesHashHardBindingValidator().Validate(contents, asset_boxes, assertion,
+                                           assertion_uri, tracker);
+  return absl::OkStatus();
+}
+
+absl::Status ContentBindingValidator::ValidateBmffHash(
+    riegeli::Reader& contents, absl::string_view assertion_uri,
+    const BmffBasedHashAssertion& assertion, bool requires_c2pa,
+    StatusTracker& tracker) const {
+  BmffHashHardBindingValidator().Validate(contents, assertion, assertion_uri,
+                                          tracker);
+  return absl::OkStatus();
+}
+
+absl::Status ContentBindingValidator::ValidateMultiAssetHash(
+    riegeli::Reader& contents, absl::string_view assertion_uri,
+    const MultiAssetHashAssertion& assertion,
+    std::vector<ContentBindingValidator::Structure> asset_structure,
+    bool requires_c2pa, AssertionFetcherRef assertion_fetcher,
+    TwoStageStatusTracker& tracker,
+    bool assertion_in_ingredient_manifest) const {
+  bool recorded_multi_asset_failure = false;
+  int64_t max_offset_validated = 0;
+
+  if (!contents.SupportsSize() || !contents.Size().has_value()) {
+    return absl::InvalidArgumentError(
+        "multi-asset hash requires an asset with a known size");
+  }
+  uint64_t asset_size = *contents.Size();
+
+  // multi-asset hash should drop all `c2pa.after` boxes
+  for (auto& structure : asset_structure) {
+    if (!structure.boxes.empty()) {
+      if (structure.boxes.back().identifier == "c2pa.after") {
+        structure.boxes.pop_back();
+      }
+    }
+  }
+
+  ABSL_RETURN_IF_ERROR(
+      ValidateParts(assertion, assertion_uri, asset_size, tracker));
+  if (tracker.HasFailuresInStage1()) {
+    // No need to validate if we already have failures.
+    return absl::OkStatus();
+  }
+
+  TwoStageStatusTracker multi_asset_tracker(
+      *static_cast<StatusTracker*>(&tracker));
+
+  for (int part_index = 0; part_index < assertion.parts_size(); ++part_index) {
+    const auto& part = assertion.parts(part_index);
+    ABSL_ASSIGN_OR_RETURN(auto locator, GetLocator(part.location()));
+
+    std::optional<int64_t> offset = std::nullopt;
+    std::optional<int64_t> length = std::nullopt;
+
+    if (std::holds_alternative<OffsetLocator>(locator)) {
+      OffsetLocator location = std::get<OffsetLocator>(locator);
+      offset = location.offset;
+      length = location.length;
+    } else {
+      BmffLocator location = std::get<BmffLocator>(locator);
+
+      if (!contents.Seek(0) || contents.pos() != 0) {
+        return contents.StatusOrAnnotate(
+            absl::InternalError("failed to seek to start of asset"));
+      }
+      ABSL_ASSIGN_OR_RETURN(bool is_supported,
+                            BmffAssessor().IsSupported(contents));
+      if (!is_supported) {
+        return absl::InvalidArgumentError(
+            "Uses a BMFF locator, but file is not a BMFF file");
+      }
+      ABSL_RETURN_IF_ERROR(IterateOverBmffBoxes(
+          contents, [&location, &offset, &length](const BmffBoxHeader& header) {
+            if (header.xpath == location.path) {
+              offset = header.start;
+              length = header.box_size;
+              return false;  // Terminate loop
+            }
+            return true;  // Continue
+          }));
+      if (!offset.has_value()) {
+        // Did not find the location
+        multi_asset_tracker.RecordFailure(
+            FailureStatusCode::kAssertionMultiAssetHashMalformed,
+            {.url = assertion_uri});
+        recorded_multi_asset_failure = true;
+      }
+    }
+
+    if (!offset.has_value() || !length.has_value()) {
+      multi_asset_tracker.RecordFailure(
+          FailureStatusCode::kAssertionMultiAssetHashMissingPart,
+          {.url = assertion_uri,
+           .explanation =
+               absl::Substitute("missing location for part $0 of $1",
+                                part_index + 1, assertion.parts_size())});
+      recorded_multi_asset_failure = true;
+      if (part.is_optional()) {
+        multi_asset_tracker.MoveStage1ToStage2();
+      } else {
+        multi_asset_tracker.WriteStage1();
+      }
+      requires_c2pa = false;
+      continue;
+    }
+
+    if (!contents.Seek(*offset) || contents.pos() != *offset ||
+        (*offset + *length) > asset_size) {
+      multi_asset_tracker.RecordFailure(
+          FailureStatusCode::kAssertionMultiAssetHashMissingPart,
+          {.url = assertion_uri,
+           .explanation =
+               absl::Substitute("missing data for part $0 of $1",
+                                part_index + 1, assertion.parts_size())});
+      if (part.is_optional()) {
+        multi_asset_tracker.MoveStage1ToStage2();
+      } else {
+        multi_asset_tracker.WriteStage1();
+      }
+      requires_c2pa = false;
+      continue;
+    }
+
+    ABSL_ASSIGN_OR_RETURN(absl::string_view manifest_label,
+                          GetManifestLabelFromAbsoluteUri(assertion_uri));
+    ABSL_ASSIGN_OR_RETURN(std::string part_hash_assertion_path,
+                          jumbf::UriResolver::GetAbsolutePathFromUri(
+                              part.hash_assertion().url(),
+                              absl::StrCat("/c2pa/", manifest_label)));
+    std::string part_hash_assertion_uri =
+        absl::StrCat("self#jumbf=", part_hash_assertion_path);
+
+    std::vector<ContentBindingValidator::Structure> part_structure =
+        (asset_structure.size() - 1) < part_index
+            ? std::vector<ContentBindingValidator::Structure>{}
+            : std::vector<ContentBindingValidator::Structure>(
+                  asset_structure.begin() + part_index, asset_structure.end());
+    ABSL_RETURN_IF_ERROR(
+        ValidateAssertion(contents, assertion_fetcher, part_hash_assertion_uri,
+                          part_structure, requires_c2pa, multi_asset_tracker,
+                          assertion_in_ingredient_manifest, *offset + *length));
+
+    if (part.is_optional()) {
+      if (multi_asset_tracker.HasFailuresInStage1()) {
+        // Optional, but failed, move to stage 2.
+        multi_asset_tracker.MoveStage1ToStage2();
+      } else {
+        // Optional, but succeeded, write.
+        multi_asset_tracker.WriteStage1();
+        max_offset_validated =
+            std::max(max_offset_validated, *offset + *length);
+      }
+    } else {
+      if (multi_asset_tracker.HasFailuresInStage1()) {
+        // Encountered a failure, but not optional, write all status codes.
+        multi_asset_tracker.WriteStage2();
+      }
+      multi_asset_tracker.WriteStage1();
+      max_offset_validated = std::max(max_offset_validated, *offset + *length);
+    }
+
+    // We only need to ensure C2PA exists on the first part.
+    requires_c2pa = false;
+  }
+
+  if (!recorded_multi_asset_failure && max_offset_validated != asset_size) {
+    // Less than the whole file was validated, if the whole file was read it's
+    // a mismatch, otherwise it's malformed.
+    multi_asset_tracker.RecordFailure(
+        contents.pos() == asset_size
+            ? FailureStatusCode::kAssertionMultiAssetHashMismatch
+            : FailureStatusCode::kAssertionMultiAssetHashMalformed,
+        {.url = assertion_uri});
+    recorded_multi_asset_failure = true;
+  }
+
+  // Write all status codes to the tracker.
+  multi_asset_tracker.WriteStage1();
+  if (tracker.HasFailuresInStage1()) {
+    // Failures were added during validation, write all stage two codes.
+    multi_asset_tracker.WriteStage2();
+    if (!recorded_multi_asset_failure) {
+      tracker.RecordFailure(FailureStatusCode::kAssertionMultiAssetHashMismatch,
+                            {.url = assertion_uri});
+    }
+  } else {
+    tracker.RecordSuccess(SuccessStatusCode::kAssertionMultiAssetHashMatch,
+                          {.url = assertion_uri});
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/bindings/validator.h b/bindings/validator.h
new file mode 100644
index 0000000..8579e6a
--- /dev/null
+++ b/bindings/validator.h
@@ -0,0 +1,136 @@
+// 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_BINDINGS_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_BINDINGS_VALIDATOR_H_
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/function_ref.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/core_registry.h"
+#include "formats/format.h"
+#include "formats/registry.h"
+#include "proto/assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/status_tracker.h"
+#include "utils/two_stage_status_tracker.h"
+
+namespace credentio {
+
+// ContentBindingValidator validates that the hard binding assertions in a
+// manifest match the structure and data of the associated asset binary content.
+class ContentBindingValidator {
+ public:
+  ContentBindingValidator() : format_registry_(CreateCoreFormatRegistry()) {}
+  ~ContentBindingValidator() = default;
+
+  // Validates the hard binding assertions in the given partial validation
+  // result and returns a full validation result.
+  absl::StatusOr<std::unique_ptr<ValidationResultProto>> Validate(
+      riegeli::Reader& contents, const Format& format,
+      std::unique_ptr<PartialValidationResultProto> partial_validation_result)
+      const;
+
+ protected:
+  struct Structure {
+    ByteRange asset;
+    std::optional<ByteRange> manifest_store_location;
+    std::vector<AssetBox> boxes;
+
+    bool operator==(const Structure& other) const {
+      return asset == other.asset &&
+             manifest_store_location == other.manifest_store_location &&
+             boxes == other.boxes;
+    }
+    bool operator!=(const Structure& other) const { return !(*this == other); }
+  };
+
+  absl::StatusOr<std::vector<ContentBindingValidator::Structure>> GetStructure(
+      riegeli::Reader& contents, const Format& format) const;
+
+ private:
+  // A FunctionRef that retrieves an assertion by its absolute JUMBF URI.
+  using AssertionFetcherRef =
+      absl::FunctionRef<const Assertion* absl_nullable(absl::string_view uri)>;
+
+  std::unique_ptr<FormatRegistry> format_registry_;
+
+  absl::StatusOr<int64_t> PopulateStructureFromBoxes(
+      std::vector<AssetBox> boxes,
+      ContentBindingValidator::Structure& structure) const;
+
+  absl::StatusOr<Structure> ExtractAssetStructure(riegeli::Reader& contents,
+                                                  const Format* format,
+                                                  int64_t end_offset) const;
+
+  absl::Status ValidateAssertion(
+      riegeli::Reader& contents, AssertionFetcherRef assertion_fetcher,
+      absl::string_view assertion_uri,
+      std::vector<ContentBindingValidator::Structure> asset_structure,
+      bool requires_c2pa, TwoStageStatusTracker& tracker,
+      bool assertion_in_ingredient_manifest, int64_t end_offset = -1) const;
+
+  absl::Status ValidateDataHash(
+      riegeli::Reader& contents, absl::string_view assertion_uri,
+      const DataHashAssertion& assertion,
+      std::optional<ByteRange> manifest_store_location, bool requires_c2pa,
+      StatusTracker& tracker, bool assertion_in_ingredient_manifest,
+      int64_t end_offset = -1) const;
+
+  absl::Status ValidateCollectionDataHash(
+      riegeli::Reader& contents, absl::string_view assertion_uri,
+      const CollectionDataHashAssertion& assertion, bool requires_c2pa,
+      StatusTracker& tracker) const;
+
+  absl::Status ValidateBoxesHash(
+      riegeli::Reader& contents, absl::string_view assertion_uri,
+      const BoxesHashAssertion& assertion,
+      std::vector<ContentBindingValidator::Structure> remaining_structure,
+      bool requires_c2pa, StatusTracker& tracker,
+      int64_t end_offset = -1) const;
+
+  absl::Status ValidateBmffHash(riegeli::Reader& contents,
+                                absl::string_view assertion_uri,
+                                const BmffBasedHashAssertion& assertion,
+                                bool requires_c2pa,
+                                StatusTracker& tracker) const;
+
+  absl::Status ValidateMultiAssetHash(
+      riegeli::Reader& contents, absl::string_view assertion_uri,
+      const MultiAssetHashAssertion& assertion,
+      std::vector<ContentBindingValidator::Structure> asset_structure,
+      bool requires_c2pa, AssertionFetcherRef assertion_fetcher,
+      TwoStageStatusTracker& tracker,
+      bool assertion_in_ingredient_manifest) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_BINDINGS_VALIDATOR_H_
diff --git a/cbor/BUILD b/cbor/BUILD
new file mode 100644
index 0000000..144ef83
--- /dev/null
+++ b/cbor/BUILD
@@ -0,0 +1,120 @@
+# 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.
+#
+
+load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library")
+load("@protobuf//bazel:proto_library.bzl", "proto_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library")
+
+package(default_visibility = ["//visibility:public"])
+
+proto_library(
+    name = "tags_proto",
+    srcs = ["tags.proto"],
+    deps = ["@protobuf//:descriptor_proto"],
+)
+
+cc_proto_library(
+    name = "tags_cc_proto",
+    deps = [":tags_proto"],
+)
+
+cc_library(
+    name = "cbor",
+    srcs = ["cbor.cc"],
+    hdrs = [
+        "cbor.h",
+        "options.h",
+    ],
+    deps = [
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@libcppbor",
+    ],
+)
+
+cc_test(
+    name = "cbor_test",
+    srcs = ["cbor_test.cc"],
+    deps = [
+        ":cbor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@googletest//:gtest_main",
+        "@libcppbor",
+    ],
+)
+
+cc_library(
+    name = "cbor_generator",
+    srcs = ["cbor_generator.cc"],
+    hdrs = ["cbor_generator.h"],
+    deps = [
+        ":tags_cc_proto",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/strings",
+        "@protobuf",
+        "@protobuf//:protoc_lib",
+    ],
+)
+
+cc_binary(
+    name = "cbor_generator_main",
+    srcs = ["cbor_generator_main.cc"],
+    deps = [
+        ":cbor_generator",
+        "@protobuf//:protoc_lib",
+    ],
+)
+
+cc_library(
+    name = "parse",
+    srcs = ["parse.cc"],
+    hdrs = ["parse.h"],
+    deps = [
+        ":cbor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@libcppbor",
+    ],
+)
+
+cc_test(
+    name = "parse_test",
+    srcs = ["parse_test.cc"],
+    deps = [
+        ":cbor",
+        ":parse",
+        "//testing:cbor_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+        "@libcppbor",
+    ],
+)
diff --git a/cbor/cbor.cc b/cbor/cbor.cc
new file mode 100644
index 0000000..e247ff3
--- /dev/null
+++ b/cbor/cbor.cc
@@ -0,0 +1,419 @@
+// 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 "cbor/cbor.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <string_view>
+
+#include "absl/base/attributes.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "cppbor/cppbor.h"
+
+namespace cbor {
+
+ItemView::Type ItemView::type() const {
+  switch (item_->type()) {
+    case cppbor::SIMPLE:
+      switch (item_->asSimple()->simpleType()) {
+        case cppbor::BOOLEAN:
+          return Type::kBool;
+        case cppbor::FLOAT:
+          return Type::kFloat;
+        case cppbor::DOUBLE:
+          return Type::kDouble;
+        default:
+          return Type::kUnknown;
+      }
+    case cppbor::TSTR:
+      return Type::kString;
+    case cppbor::BSTR:
+      return Type::kByteString;
+    case cppbor::NINT:
+      return Type::kInt64;
+    case cppbor::UINT:
+      return Type::kUint64;
+    case cppbor::MAP:
+      return Type::kMap;
+    case cppbor::ARRAY:
+      return Type::kArray;
+    default:
+      return Type::kUnknown;
+  }
+}
+
+bool ItemView::IsNull() const {
+  if (const auto* s = item_->asSimple(); s != nullptr) {
+    if (const auto* v = s->asNull(); v != nullptr) {
+      return true;
+    }
+  }
+  return false;
+}
+
+absl::StatusOr<bool> ItemView::GetBool() const {
+  if (const auto* s = item_->asSimple(); s != nullptr) {
+    if (const auto* v = s->asBool(); v != nullptr) {
+      return v->value();
+    }
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not a bool. Type: ", item_->type()));
+}
+
+absl::StatusOr<float> ItemView::GetFloat() const {
+  if (const auto* s = item_->asSimple(); s != nullptr) {
+    if (const auto* v = s->asFloat(); v != nullptr) {
+      return v->value();
+    }
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not a float. Type: ", item_->type()));
+}
+
+absl::StatusOr<double> ItemView::GetDouble() const {
+  if (const auto* s = item_->asSimple(); s != nullptr) {
+    if (const auto* v = s->asDouble(); v != nullptr) {
+      return v->value();
+    }
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not a double. Type: ", item_->type()));
+}
+
+absl::StatusOr<int64_t> ItemView::GetInt64() const {
+  if (const auto* v = item_->asInt(); v != nullptr) {
+    return v->value();
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not an int. Type: ", item_->type()));
+}
+
+absl::StatusOr<uint64_t> ItemView::GetUint64() const {
+  if (const auto* v = item_->asUint(); v != nullptr) {
+    return v->value();
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not an uint. Type: ", item_->type()));
+}
+
+absl::StatusOr<absl::string_view> ItemView::GetString() const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (const auto* v = item_->asTstr(); v != nullptr) {
+    return v->value();
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not a string. Type: ", item_->type()));
+}
+
+absl::StatusOr<absl::string_view> ItemView::GetByteString() const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (const auto* v = item_->asBstr(); v != nullptr) {
+    absl::string_view str(reinterpret_cast<const char*>(v->value().data()),
+                          v->value().size());
+    return str;
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not a byte string. Type: ", item_->type()));
+}
+
+absl::StatusOr<MapView> ItemView::GetMap() const ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (const auto* v = item_->asMap(); v != nullptr) {
+    return MapView(v);
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not a map. Type: ", item_->type()));
+}
+
+absl::StatusOr<ArrayView> ItemView::GetArray() const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (const auto* v = item_->asArray(); v != nullptr) {
+    return ArrayView(v);
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("CBOR item is not an array. Type: ", item_->type()));
+}
+
+std::string ItemView::ToString() const { return item_->toString(); }
+
+absl::StatusOr<ItemView> MapView::Get(uint32_t key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  const auto& item = map_->get(key);
+  if (!item) {
+    return absl::InvalidArgumentError(absl::StrCat("Key not found: ", key));
+  }
+  return ItemView(item.get());
+}
+
+absl::StatusOr<ItemView> MapView::Get(absl::string_view key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  const auto& item = map_->get(std::string_view(key));
+  if (!item) {
+    return absl::InvalidArgumentError(absl::StrCat("Key not found: ", key));
+  }
+  return ItemView(item.get());
+}
+
+std::optional<ItemView> MapView::GetOptional(absl::string_view key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  const auto& item = map_->get(std::string_view(key));
+  if (!item) {
+    return std::nullopt;
+  }
+  return ItemView(item.get());
+}
+
+absl::StatusOr<bool> MapView::GetBool(absl::string_view key) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  return item.GetBool();
+}
+
+std::optional<bool> MapView::GetOptionalBool(absl::string_view key) const {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetBool(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+absl::StatusOr<float> MapView::GetFloat(absl::string_view key) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  return item.GetFloat();
+}
+
+std::optional<float> MapView::GetOptionalFloat(absl::string_view key) const {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetFloat(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+absl::StatusOr<double> MapView::GetDouble(absl::string_view key) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  return item.GetDouble();
+}
+
+std::optional<double> MapView::GetOptionalDouble(absl::string_view key) const {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetDouble(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+absl::StatusOr<absl::string_view> MapView::GetString(
+    absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  ABSL_ASSIGN_OR_RETURN(auto str, item.GetString());
+  return str;
+}
+
+std::optional<absl::string_view> MapView::GetOptionalString(
+    absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetString(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+absl::StatusOr<absl::string_view> MapView::GetByteString(uint32_t key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  ABSL_ASSIGN_OR_RETURN(auto str, item.GetByteString());
+  return str;
+}
+
+absl::StatusOr<absl::string_view> MapView::GetByteString(
+    absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  ABSL_ASSIGN_OR_RETURN(auto str, item.GetByteString());
+  return str;
+}
+
+std::optional<absl::string_view> MapView::GetOptionalByteString(
+    absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetByteString(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+std::optional<MapView> MapView::GetOptionalMap(absl::string_view key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetMap(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+absl::StatusOr<int64_t> MapView::GetInt64(absl::string_view key) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  return item.GetInt64();
+}
+
+std::optional<int64_t> MapView::GetOptionalInt64(absl::string_view key) const {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetInt64(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+absl::StatusOr<int64_t> MapView::GetInt64(uint32_t key) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  return item.GetInt64();
+}
+
+absl::StatusOr<uint64_t> MapView::GetUint64(absl::string_view key) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  return item.GetUint64();
+}
+
+std::optional<int64_t> MapView::GetOptionalUint64(absl::string_view key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetUint64(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+absl::StatusOr<MapView> MapView::GetMap(absl::string_view key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  ABSL_ASSIGN_OR_RETURN(auto map, item.GetMap());
+  return map;
+}
+
+absl::StatusOr<ArrayView> MapView::GetArray(uint32_t key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  ABSL_ASSIGN_OR_RETURN(auto array, item.GetArray());
+  return array;
+}
+
+absl::StatusOr<ArrayView> MapView::GetArray(absl::string_view key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(key));
+  ABSL_ASSIGN_OR_RETURN(auto array, item.GetArray());
+  return array;
+}
+
+std::optional<ArrayView> MapView::GetOptionalArray(absl::string_view key) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (auto item = GetOptional(key); item.has_value()) {
+    if (auto value = item->GetArray(); value.ok()) {
+      return *value;
+    }
+  }
+  return std::nullopt;
+}
+
+uint32_t ArrayView::size() const { return array_->size(); }
+
+ItemView::Type ArrayView::value_type(uint32_t index) const {
+  if (index >= size()) {
+    return ItemView::Type::kUnknown;
+  }
+  ItemView item(array_->get(index).get());
+  return item.type();
+}
+
+absl::StatusOr<ItemView> ArrayView::Get(uint32_t index) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  if (index >= array_->size()) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Index out of range: ", index, " >= ", array_->size()));
+  }
+  return ItemView(array_->get(index).get());
+}
+
+absl::StatusOr<bool> ArrayView::GetBool(uint32_t index) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  return item.GetBool();
+}
+
+absl::StatusOr<float> ArrayView::GetFloat(uint32_t index) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  return item.GetFloat();
+}
+
+absl::StatusOr<double> ArrayView::GetDouble(uint32_t index) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  return item.GetDouble();
+}
+
+absl::StatusOr<absl::string_view> ArrayView::GetString(uint32_t index) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  ABSL_ASSIGN_OR_RETURN(auto str, item.GetString());
+  return str;
+}
+
+absl::StatusOr<absl::string_view> ArrayView::GetByteString(uint32_t index) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  ABSL_ASSIGN_OR_RETURN(auto str, item.GetByteString());
+  return str;
+}
+
+absl::StatusOr<int64_t> ArrayView::GetInt64(uint32_t index) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  return item.GetInt64();
+}
+
+absl::StatusOr<uint64_t> ArrayView::GetUint64(uint32_t index) const {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  return item.GetUint64();
+}
+
+absl::StatusOr<MapView> ArrayView::GetMap(uint32_t index) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  ABSL_ASSIGN_OR_RETURN(auto map, item.GetMap());
+  return map;
+}
+
+absl::StatusOr<ArrayView> ArrayView::GetArray(uint32_t index) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  ABSL_ASSIGN_OR_RETURN(auto array, item.GetArray());
+  return array;
+}
+
+absl::StatusOr<bool> ArrayView::IsNull(uint32_t index) const
+    ABSL_ATTRIBUTE_LIFETIME_BOUND {
+  ABSL_ASSIGN_OR_RETURN(auto item, Get(index));
+  return item.IsNull();
+}
+}  // namespace cbor
diff --git a/cbor/cbor.h b/cbor/cbor.h
new file mode 100644
index 0000000..b66f91f
--- /dev/null
+++ b/cbor/cbor.h
@@ -0,0 +1,181 @@
+// 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_CBOR_CBOR_H_
+#define THIRD_PARTY_CREDENTIO_CBOR_CBOR_H_
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/base/attributes.h"
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "cppbor/cppbor.h"
+
+namespace cbor {
+
+class MapView;
+class ArrayView;
+
+class ItemView {
+ public:
+  enum class Type {
+    kUnknown,
+    kBool,
+    kFloat,
+    kDouble,
+    kString,
+    kByteString,
+    kInt64,
+    kUint64,
+    kMap,
+    kArray,
+  };
+  explicit ItemView(
+      const cppbor::Item* absl_nonnull item ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : item_(ABSL_DIE_IF_NULL(item)) {}
+  Type type() const;
+  absl::StatusOr<bool> GetBool() const;
+  absl::StatusOr<float> GetFloat() const;
+  absl::StatusOr<double> GetDouble() const;
+  absl::StatusOr<int64_t> GetInt64() const;
+  absl::StatusOr<uint64_t> GetUint64() const;
+  absl::StatusOr<absl::string_view> GetString() const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<absl::string_view> GetByteString() const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<MapView> GetMap() const ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<ArrayView> GetArray() const ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  // Gets the full encoded representation of this item.
+  std::string ToString() const;
+  bool IsNull() const;
+
+ private:
+  const cppbor::Item* absl_nonnull item_;
+};
+
+class MapView {
+ public:
+  explicit MapView(
+      const cppbor::Map* absl_nonnull map ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : map_(ABSL_DIE_IF_NULL(map)) {}
+  absl::StatusOr<bool> GetBool(absl::string_view key) const;
+  std::optional<bool> GetOptionalBool(absl::string_view key) const;
+  absl::StatusOr<float> GetFloat(absl::string_view key) const;
+  std::optional<float> GetOptionalFloat(absl::string_view key) const;
+  absl::StatusOr<double> GetDouble(absl::string_view key) const;
+  std::optional<double> GetOptionalDouble(absl::string_view key) const;
+  absl::StatusOr<absl::string_view> GetString(absl::string_view key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  // Returns std::nullopt if the key is not found or the value is not a string.
+  std::optional<absl::string_view> GetOptionalString(
+      absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<absl::string_view> GetByteString(uint32_t key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<absl::string_view> GetByteString(absl::string_view key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  std::optional<absl::string_view> GetOptionalByteString(
+      absl::string_view key) const ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<int64_t> GetInt64(absl::string_view key) const;
+  absl::StatusOr<int64_t> GetInt64(uint32_t key) const;
+  std::optional<int64_t> GetOptionalInt64(absl::string_view key) const;
+  absl::StatusOr<uint64_t> GetUint64(absl::string_view key) const;
+  std::optional<int64_t> GetOptionalUint64(absl::string_view key) const;
+  absl::StatusOr<MapView> GetMap(absl::string_view key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  std::optional<MapView> GetOptionalMap(absl::string_view key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<ArrayView> GetArray(uint32_t key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<ArrayView> GetArray(absl::string_view key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  std::optional<ArrayView> GetOptionalArray(absl::string_view key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  // Gets the full encoded representation of this map.
+  std::string ToString() const { return map_->toString(); }
+
+ private:
+  absl::StatusOr<ItemView> Get(uint32_t key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<ItemView> Get(absl::string_view key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  std::optional<ItemView> GetOptional(absl::string_view key) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  const cppbor::Map* absl_nonnull map_;
+};
+
+class ArrayView {
+ public:
+  explicit ArrayView(
+      const cppbor::Array* absl_nonnull array ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : array_(ABSL_DIE_IF_NULL(array)) {}
+  uint32_t size() const;
+  bool empty() const { return size() == 0; }
+  ItemView::Type value_type(uint32_t index) const;
+  absl::StatusOr<bool> GetBool(uint32_t index) const;
+  absl::StatusOr<float> GetFloat(uint32_t index) const;
+  absl::StatusOr<double> GetDouble(uint32_t index) const;
+  absl::StatusOr<absl::string_view> GetString(uint32_t index) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<absl::string_view> GetByteString(uint32_t index) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<int64_t> GetInt64(uint32_t index) const;
+  absl::StatusOr<uint64_t> GetUint64(uint32_t index) const;
+  absl::StatusOr<MapView> GetMap(uint32_t index) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<ArrayView> GetArray(uint32_t index) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  absl::StatusOr<bool> IsNull(uint32_t index) const;
+  absl::StatusOr<ItemView> Get(uint32_t index) const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND;
+  // Gets the full encoded representation of this array.
+  std::string ToString() const { return array_->toString(); }
+
+ private:
+  const cppbor::Array* absl_nonnull array_;
+};
+
+class ParseResult {
+ public:
+  explicit ParseResult(std::unique_ptr<cppbor::Item> item)
+      : item_(std::move(item)) {}
+  absl::StatusOr<ArrayView> AsArray() const ABSL_ATTRIBUTE_LIFETIME_BOUND {
+    if (!item_->asArray()) {
+      return absl::InvalidArgumentError("CBOR parsed result is not an array");
+    }
+    return ArrayView(item_->asArray());
+  }
+  absl::StatusOr<MapView> AsMap() const ABSL_ATTRIBUTE_LIFETIME_BOUND {
+    if (!item_->asMap()) {
+      return absl::InvalidArgumentError("CBOR parsed result is not a map");
+    }
+    return MapView(item_->asMap());
+  }
+  // Gets the full encoded representation of this item.
+  std::string ToString() const { return item_->toString(); }
+
+ private:
+  std::unique_ptr<cppbor::Item> item_;
+};
+
+}  // namespace cbor
+
+#endif  // THIRD_PARTY_CREDENTIO_CBOR_CBOR_H_
diff --git a/cbor/cbor_codegen.bzl b/cbor/cbor_codegen.bzl
new file mode 100644
index 0000000..d6c8d1c
--- /dev/null
+++ b/cbor/cbor_codegen.bzl
@@ -0,0 +1,170 @@
+# 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.
+#
+
+"""Starlark build rules for CBOR code generation."""
+
+load("@protobuf//bazel/common:proto_info.bzl", "ProtoInfo")
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+_PROTOC = "@protobuf//:protoc"
+_PLUGIN = "//cbor:cbor_generator_main"
+_CLANG_FORMAT = "@protobuf//:protoc"
+
+def cc_cbor_proto_library(
+        *,
+        name,
+        srcs,
+        deps = [],
+        **kwargs):
+    """Generates C++ CBOR code from a proto_library.
+
+    Args:
+      name: name of the generated cc_library
+      srcs: proto_library rules that wrap the *.proto files; must contain exactly one entry
+      deps: cc_proto_library rules that wrap the proto_library above
+      **kwargs: extra attributes for the generated cc_library
+    """
+    if len(srcs) != 1:
+        fail("srcs must contain exactly one entry", attr = "srcs")
+
+    proto_rule_name = srcs[0]
+    if proto_rule_name.startswith(":"):
+        proto_rule_name = proto_rule_name[1:]
+
+    proto_rule = native.existing_rule(proto_rule_name)
+    if not proto_rule:
+        fail("'%s' not found. Define it before cbor_proto_library." % proto_rule_name)
+
+    if proto_rule["kind"] != "proto_library":
+        fail("src '%s' has kind '%s', expecting 'proto_library'." % (proto_rule_name, proto_rule["kind"]))
+
+    proto_srcs = list(proto_rule["srcs"])
+
+    generated_srcs = [src.removesuffix(".proto") + ".cbor.cc" for src in proto_srcs]
+    generated_hdrs = [src.removesuffix(".proto") + ".cbor.h" for src in proto_srcs]
+
+    # Automatically infer corresponding `_cbor_proto` dependencies for the `proto_library` dependencies
+    # within the same package. For example, if the proto_library depends on :foo_proto, we check if
+    # :foo_cbor_proto exists in the same package and add it to ensure transitive header resolution.
+    auto_deps = []
+    current_pkg = "//" + native.package_name() + ":"
+    for dep in proto_rule.get("deps", []):
+        target_name = None
+        if dep.startswith(current_pkg):
+            target_name = dep.removeprefix(current_pkg)
+        elif dep.startswith(":"):
+            target_name = dep[1:]
+
+        if target_name and target_name.endswith("_proto"):
+            cbor_name = target_name.removesuffix("_proto") + "_cbor_proto"
+            if native.existing_rule(cbor_name):
+                auto_deps.append(":" + cbor_name)
+
+    generated_target_name = name + "_gen"
+
+    _generate_cbor_code(
+        name = generated_target_name,
+        srcs = srcs,
+        generated_files = generated_srcs + generated_hdrs,
+        visibility = ["//visibility:private"],
+    )
+
+    cc_library(
+        name = name,
+        srcs = generated_srcs,
+        hdrs = generated_hdrs,
+        deps = deps + auto_deps + [
+            "//cbor",
+            "@abseil-cpp//absl/status",
+            "@abseil-cpp//absl/status:status_macros",
+            "@abseil-cpp//absl/status:statusor",
+            "@libcppbor",
+        ],
+        **kwargs
+    )
+
+def _generate_cbor_code_impl(ctx):
+    proto_sources = depset(
+        [f for dep in ctx.attr.srcs for f in dep[ProtoInfo].direct_sources],
+    )
+    proto_imports = depset(
+        transitive = [dep[ProtoInfo].transitive_sources for dep in ctx.attr.srcs],
+    )
+
+    unique_roots = {}
+    all_files = depset(transitive = [proto_imports, proto_sources]).to_list()
+    for f in all_files:
+        idx = f.path.rfind("google/protobuf/")
+        if idx != -1:
+            root = f.path[:idx]
+            if root:
+                unique_roots[root] = True
+
+    protoc_paths = ["-I."] + ["-I" + r for r in unique_roots] + ["-I" + f.short_path + "=" + f.path for f in all_files]
+
+    args = ctx.actions.args()
+    args.add("--plugin=protoc-gen-cbor=" + ctx.executable._plugin.path)
+    args.add_all(protoc_paths)
+    args.add("--cbor_out", ctx.bin_dir.path)
+    args.add_all(proto_sources)
+    args.set_param_file_format("multiline")
+    args.use_param_file(param_file_arg = "@%s")
+
+    output_paths = [f.path for f in ctx.outputs.generated_files]
+    outputs_str = " ".join(output_paths)
+
+    command = "protoc_path=$1; clang_format_path=$2; shift 2; \"$protoc_path\" \"$@\""
+
+    ctx.actions.run_shell(
+        arguments = [ctx.executable._proto_compiler.path, ctx.executable._clang_format.path, args],
+        command = command,
+        inputs = depset(transitive = [proto_sources, proto_imports]),
+        mnemonic = "GenerateCborCode",
+        outputs = ctx.outputs.generated_files,
+        progress_message = "Generating and formatting CBOR code for %{label}",
+        tools = depset([
+            ctx.executable._proto_compiler,
+            ctx.executable._plugin,
+            ctx.executable._clang_format,
+        ]),
+    )
+    return [DefaultInfo(files = depset(ctx.outputs.generated_files))]
+
+_generate_cbor_code = rule(
+    attrs = {
+        "srcs": attr.label_list(
+            allow_empty = False,
+            mandatory = True,
+            providers = [ProtoInfo],
+        ),
+        "_proto_compiler": attr.label(
+            default = Label(_PROTOC),
+            executable = True,
+            cfg = "exec",
+        ),
+        "_plugin": attr.label(
+            default = Label(_PLUGIN),
+            executable = True,
+            cfg = "exec",
+        ),
+        "_clang_format": attr.label(
+            default = Label(_CLANG_FORMAT),
+            executable = True,
+            cfg = "exec",
+        ),
+        "generated_files": attr.output_list(),
+    },
+    implementation = _generate_cbor_code_impl,
+)
diff --git a/cbor/cbor_generator.cc b/cbor/cbor_generator.cc
new file mode 100644
index 0000000..2faba71
--- /dev/null
+++ b/cbor/cbor_generator.cc
@@ -0,0 +1,574 @@
+// 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 "cbor/cbor_generator.h"
+
+#include <memory>
+#include <string>
+
+#include "absl/base/no_destructor.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/strings/ascii.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_replace.h"
+#include "absl/strings/string_view.h"
+#include "cbor/tags.pb.h"
+#include "google/protobuf/compiler/code_generator.h"
+#include "google/protobuf/descriptor.h"
+#include "google/protobuf/io/printer.h"
+#include "google/protobuf/io/zero_copy_stream.h"
+
+namespace cbor {
+namespace {
+
+std::string QualifiedCppName(const google::protobuf::Descriptor* descriptor) {
+  return absl::StrReplaceAll(descriptor->full_name(), {{".", "::"}});
+}
+
+absl::string_view StripProtoExtension(absl::string_view filename) {
+  if (absl::EndsWith(filename, ".proto")) {
+    return filename.substr(0, filename.size() - 6);
+  }
+  return filename;
+}
+
+const cbor::Tags& GetTags(const google::protobuf::FieldDescriptor* field) {
+  if (field->options().HasExtension(cbor::tags)) {
+    return field->options().GetExtension(cbor::tags);
+  }
+  static const absl::NoDestructor<cbor::Tags> default_tags;
+  return *default_tags;
+}
+
+std::string GetCborKey(const google::protobuf::FieldDescriptor* field) {
+  const auto& tags = GetTags(field);
+  if (!tags.name().empty()) {
+    return std::string(tags.name());
+  }
+  return std::string(field->name());
+}
+
+const absl::flat_hash_map<google::protobuf::FieldDescriptor::Type,
+                          absl::string_view>&
+GetProtoTypeToCborType() {
+  static const absl::NoDestructor<absl::flat_hash_map<
+      google::protobuf::FieldDescriptor::Type, absl::string_view>>
+      proto_type_to_cbor_type({
+          {google::protobuf::FieldDescriptor::TYPE_MESSAGE, "Map"},
+          {google::protobuf::FieldDescriptor::TYPE_STRING, "String"},
+          {google::protobuf::FieldDescriptor::TYPE_UINT64, "Uint64"},
+          {google::protobuf::FieldDescriptor::TYPE_INT64, "Int64"},
+          {google::protobuf::FieldDescriptor::TYPE_UINT32, "Uint64"},
+          {google::protobuf::FieldDescriptor::TYPE_INT32, "Int64"},
+          {google::protobuf::FieldDescriptor::TYPE_BOOL, "Bool"},
+          {google::protobuf::FieldDescriptor::TYPE_FLOAT, "Float"},
+          {google::protobuf::FieldDescriptor::TYPE_DOUBLE, "Double"},
+          {google::protobuf::FieldDescriptor::TYPE_BYTES, "ByteString"},
+      });
+  return *proto_type_to_cbor_type;
+}
+
+absl::string_view GetCborTypeName(
+    const google::protobuf::FieldDescriptor* field) {
+  const auto& tags = GetTags(field);
+  if (tags.type() != cbor::Tags::TYPE_UNSPECIFIED) {
+    switch (tags.type()) {
+      case cbor::Tags::TYPE_UINT:
+        return "Uint64";
+      case cbor::Tags::TYPE_NINT:
+        return "Int64";
+      case cbor::Tags::TYPE_BYTE_STRING:
+        return "ByteString";
+      case cbor::Tags::TYPE_TEXT_STRING:
+        return "String";
+      default:
+        break;
+    }
+  }
+  auto it = GetProtoTypeToCborType().find(field->type());
+  if (it != GetProtoTypeToCborType().end()) {
+    return it->second;
+  }
+  return "";
+}
+
+void GenerateMessageHeader(const google::protobuf::Descriptor* desc,
+                           google::protobuf::io::Printer& printer) {
+  printer.Emit({{"name", QualifiedCppName(desc)}}, R"cc(
+    absl::Status ToProto(const cbor::MapView& map, $name$* proto);
+    absl::StatusOr<std::unique_ptr<cppbor::Map>> FromProto(const $name$& proto);
+    absl::StatusOr<std::unique_ptr<cppbor::Map>> FromProto(
+        const $name$& proto, FromProtoOptions options);
+  )cc");
+  for (int i = 0; i < desc->nested_type_count(); ++i) {
+    GenerateMessageHeader(desc->nested_type(i), printer);
+  }
+}
+
+void GenerateToProtoBody(const google::protobuf::Descriptor* desc,
+                         google::protobuf::io::Printer& printer) {
+  printer.Emit({{"name", QualifiedCppName(desc)}}, R"cc(
+    absl::Status ToProto(const cbor::MapView& map, $name$* proto) {
+  )cc");
+  {
+    auto indent = printer.WithIndent();
+    for (int i = 0; i < desc->field_count(); ++i) {
+      const auto* field = desc->field(i);
+      const auto& tags = GetTags(field);
+      if (tags.mode() == cbor::Tags::MODE_WRITE_ONLY ||
+          tags.mode() == cbor::Tags::MODE_SKIP) {
+        continue;
+      }
+
+      std::string key = GetCborKey(field);
+      printer.Emit(R"cc(
+        {
+      )cc");
+      {
+        auto scope_indent = printer.WithIndent();
+        if (field->is_repeated()) {
+          if (field->type() ==
+              google::protobuf::FieldDescriptor::TYPE_MESSAGE) {
+            printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+              auto array_view = map.GetOptionalArray("$key$");
+            )cc");
+            if (tags.is_required()) {
+              printer.Emit({{"key", key}}, R"cc(
+                if (!array_view.has_value()) {
+                  return absl::InvalidArgumentError(
+                      "Missing required field: "
+                      "$key$");
+                }
+              )cc");
+            }
+            if (tags.disallow_empty()) {
+              printer.Emit({{"key", key}}, R"cc(
+                if (array_view.has_value() && array_view->size() == 0) {
+                  return absl::InvalidArgumentError(
+                      "Field $key$ cannot be "
+                      "empty");
+                }
+              )cc");
+            }
+            printer.Emit({{"field", field->name()}}, R"cc(
+              if (array_view.has_value()) {
+                for (uint32_t i = 0; i < array_view->size(); ++i) {
+                  ABSL_ASSIGN_OR_RETURN(auto item, array_view->GetMap(i));
+                  ABSL_RETURN_IF_ERROR(ToProto(std::move(item), proto->add_$field$()));
+                }
+              }
+            )cc");
+          } else if (!GetCborTypeName(field).empty()) {
+            absl::string_view type = GetCborTypeName(field);
+            printer.Emit(
+                {{"field", field->name()}, {"key", key}, {"type", type}}, R"cc(
+                  auto array_view = map.GetOptionalArray("$key$");
+                )cc");
+            if (tags.is_required()) {
+              printer.Emit({{"key", key}}, R"cc(
+                if (!array_view.has_value()) {
+                  return absl::InvalidArgumentError(
+                      "Missing required field: "
+                      "$key$");
+                }
+              )cc");
+            }
+            if (tags.disallow_empty()) {
+              printer.Emit({{"key", key}}, R"cc(
+                if (array_view.has_value() && array_view->size() == 0) {
+                  return absl::InvalidArgumentError(
+                      "Field $key$ cannot be "
+                      "empty");
+                }
+              )cc");
+            }
+            printer.Emit({{"field", field->name()}, {"type", type}}, R"cc(
+              if (array_view.has_value()) {
+                for (uint32_t i = 0; i < array_view->size(); ++i) {
+                  ABSL_ASSIGN_OR_RETURN(auto item, array_view->Get$type$(i));
+                  proto->add_$field$(std::move(item));
+                }
+              }
+            )cc");
+          }
+        } else {
+          if (field->type() ==
+              google::protobuf::FieldDescriptor::TYPE_MESSAGE) {
+            if (tags.is_required()) {
+              printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+                auto map_view = map.GetOptionalMap("$key$");
+                if (!map_view.has_value()) {
+                  return absl::InvalidArgumentError(
+                      "Missing required field: "
+                      "$key$");
+                }
+                ABSL_RETURN_IF_ERROR(ToProto(*map_view, proto->mutable_$field$()));
+              )cc");
+            } else {
+              printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+                auto map_view = map.GetOptionalMap("$key$");
+                if (map_view.has_value()) {
+                  ABSL_RETURN_IF_ERROR(ToProto(*map_view, proto->mutable_$field$()));
+                }
+              )cc");
+            }
+          } else if (!GetCborTypeName(field).empty()) {
+            absl::string_view type = GetCborTypeName(field);
+            if (tags.is_required()) {
+              printer.Emit(
+                  {{"field", field->name()}, {"key", key}, {"type", type}},
+                  R"cc(
+                    auto val = map.GetOptional$type$("$key$");
+                    if (!val.has_value()) {
+                      return absl::InvalidArgumentError(
+                          "Missing required "
+                          "field: $key$");
+                    }
+                    proto->set_$field$(*val);
+                  )cc");
+            } else {
+              printer.Emit(
+                  {{"field", field->name()}, {"key", key}, {"type", type}},
+                  R"cc(
+                    auto val = map.GetOptional$type$("$key$");
+                    if (val.has_value()) {
+                      proto->set_$field$(*val);
+                    }
+                  )cc");
+            }
+          }
+        }
+      }
+      printer.Emit(R"cc(
+        }
+      )cc");
+    }
+    printer.Emit(R"cc(
+      return absl::OkStatus();
+    )cc");
+  }
+  printer.Emit(R"cc(
+    }
+  )cc");
+  for (int i = 0; i < desc->nested_type_count(); ++i) {
+    GenerateToProtoBody(desc->nested_type(i), printer);
+  }
+}
+
+void GenerateFromProtoBody(const google::protobuf::Descriptor* desc,
+                           google::protobuf::io::Printer& printer) {
+  printer.Emit({{"name", QualifiedCppName(desc)}}, R"cc(
+    absl::StatusOr<std::unique_ptr<cppbor::Map>> FromProto(
+        const $name$& proto) {
+      return FromProto(proto, FromProtoOptions{});
+    }
+
+    absl::StatusOr<std::unique_ptr<cppbor::Map>> FromProto(
+        const $name$& proto, FromProtoOptions options) {
+      auto map = std::make_unique<cppbor::Map>();
+  )cc");
+  {
+    auto indent = printer.WithIndent();
+    for (int i = 0; i < desc->field_count(); ++i) {
+      const auto* field = desc->field(i);
+      const auto& tags = GetTags(field);
+
+      if (tags.mode() == cbor::Tags::MODE_READ_ONLY ||
+          tags.mode() == cbor::Tags::MODE_SKIP) {
+        continue;
+      }
+
+      std::string key = GetCborKey(field);
+      if (field->is_repeated()) {
+        if (tags.is_required() && tags.disallow_empty()) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            if (!options.skip_validation && proto.$field$_size() == 0) {
+              return absl::InvalidArgumentError("Field $key$ cannot be empty");
+            }
+          )cc");
+        }
+        if (tags.is_required()) {
+          printer.Emit(R"cc(
+            {
+          )cc");
+        } else {
+          printer.Emit({{"field", field->name()}}, R"cc(
+            if (proto.$field$_size() > 0) {
+          )cc");
+        }
+
+        if (field->type() == google::protobuf::FieldDescriptor::TYPE_MESSAGE) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            auto array = std::make_unique<cppbor::Array>();
+            for (int i = 0; i < proto.$field$_size(); ++i) {
+              ABSL_ASSIGN_OR_RETURN(auto item, FromProto(proto.$field$(i)));
+              array->add(std::move(item));
+            }
+            map->add("$key$", std::move(array));
+          )cc");
+        } else if (field->type() ==
+                   google::protobuf::FieldDescriptor::TYPE_BYTES) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            auto array = std::make_unique<cppbor::Array>();
+            for (int i = 0; i < proto.$field$_size(); ++i) {
+              array->add(cppbor::Bstr(std::string(proto.$field$(i))));
+            }
+            map->add("$key$", std::move(array));
+          )cc");
+        } else if (field->type() ==
+                   google::protobuf::FieldDescriptor::TYPE_STRING) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            auto array = std::make_unique<cppbor::Array>();
+            for (int i = 0; i < proto.$field$_size(); ++i) {
+              array->add(cppbor::Tstr(std::string(proto.$field$(i))));
+            }
+            map->add("$key$", std::move(array));
+          )cc");
+        } else if (field->type() ==
+                   google::protobuf::FieldDescriptor::TYPE_FLOAT) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            auto array = std::make_unique<cppbor::Array>();
+            for (int i = 0; i < proto.$field$_size(); ++i) {
+              array->add(cppbor::Float(proto.$field$(i)));
+            }
+            map->add("$key$", std::move(array));
+          )cc");
+        } else if (field->type() ==
+                   google::protobuf::FieldDescriptor::TYPE_DOUBLE) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            auto array = std::make_unique<cppbor::Array>();
+            for (int i = 0; i < proto.$field$_size(); ++i) {
+              array->add(cppbor::Double(proto.$field$(i)));
+            }
+            map->add("$key$", std::move(array));
+          )cc");
+        } else {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            auto array = std::make_unique<cppbor::Array>();
+            for (int i = 0; i < proto.$field$_size(); ++i) {
+              array->add(proto.$field$(i));
+            }
+            map->add("$key$", std::move(array));
+          )cc");
+        }
+
+        printer.Emit(R"cc(
+          }
+        )cc");
+      } else {
+        if (tags.is_required()) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            if (!options.skip_validation && !proto.has_$field$()) {
+              return absl::InvalidArgumentError(
+                  "Missing required field: "
+                  "$key$");
+            }
+          )cc");
+        }
+        if (field->type() == google::protobuf::FieldDescriptor::TYPE_MESSAGE) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            if (proto.has_$field$()) {
+              ABSL_ASSIGN_OR_RETURN(auto item, FromProto(proto.$field$()));
+              map->add("$key$", std::move(item));
+            }
+          )cc");
+        } else if (field->type() ==
+                   google::protobuf::FieldDescriptor::TYPE_BYTES) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            if (proto.has_$field$()) {
+              map->add("$key$", cppbor::Bstr(std::string(proto.$field$())));
+            }
+          )cc");
+        } else if (field->type() ==
+                   google::protobuf::FieldDescriptor::TYPE_STRING) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            if (proto.has_$field$()) {
+              map->add("$key$", cppbor::Tstr(std::string(proto.$field$())));
+            }
+          )cc");
+        } else if (field->type() ==
+                   google::protobuf::FieldDescriptor::TYPE_FLOAT) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            if (proto.has_$field$()) {
+              map->add("$key$", cppbor::Float(proto.$field$()));
+            }
+          )cc");
+        } else if (field->type() ==
+                   google::protobuf::FieldDescriptor::TYPE_DOUBLE) {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            if (proto.has_$field$()) {
+              map->add("$key$", cppbor::Double(proto.$field$()));
+            }
+          )cc");
+        } else {
+          printer.Emit({{"field", field->name()}, {"key", key}}, R"cc(
+            if (proto.has_$field$()) {
+              map->add("$key$", proto.$field$());
+            }
+          )cc");
+        }
+      }
+    }
+    printer.Emit(R"cc(
+      return map;
+    )cc");
+  }
+  printer.Emit(R"cc(
+    }
+  )cc");
+  for (int i = 0; i < desc->nested_type_count(); ++i) {
+    GenerateFromProtoBody(desc->nested_type(i), printer);
+  }
+}
+
+void CollectUsedDeps(
+    const google::protobuf::Descriptor* descriptor,
+    const google::protobuf::FileDescriptor* current_file,
+    absl::flat_hash_set<const google::protobuf::FileDescriptor*>& used_deps) {
+  for (int i = 0; i < descriptor->field_count(); ++i) {
+    const auto* f = descriptor->field(i);
+    if (f->type() == google::protobuf::FieldDescriptor::TYPE_MESSAGE) {
+      if (GetTags(f).mode() == cbor::Tags::MODE_SKIP) {
+        continue;
+      }
+      const auto* m = f->message_type();
+      if (m->file() != current_file) {
+        used_deps.insert(m->file());
+      }
+    }
+  }
+  for (int i = 0; i < descriptor->nested_type_count(); ++i) {
+    CollectUsedDeps(descriptor->nested_type(i), current_file, used_deps);
+  }
+}
+
+void GenerateHeader(const google::protobuf::FileDescriptor* file,
+                    absl::string_view base_name,
+                    google::protobuf::compiler::GeneratorContext* context) {
+  std::unique_ptr<google::protobuf::io::ZeroCopyOutputStream> h_stream(
+      context->Open(absl::StrCat(base_name, ".cbor.h")));
+  google::protobuf::io::Printer h_printer(h_stream.get());
+
+  std::string guard = absl::AsciiStrToUpper(absl::StrReplaceAll(
+      absl::StrCat(base_name, "_CBOR_H_"), {{"/", "_"}, {".", "_"}}));
+  h_printer.Emit({{"guard", guard}}, R"cc(
+#ifndef $guard$
+#define $guard$
+  )cc");
+
+  if (file->message_type_count() > 0) {
+    h_printer.Emit(R"cc(
+#include <memory>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "cbor/cbor.h"
+#include "cbor/options.h"
+#include "cppbor/cppbor.h"
+    )cc");
+  }
+
+  h_printer.Emit({{"base", base_name}}, R"cc(
+#include "$base$.pb.h"
+  )cc");
+
+  for (int i = 0; i < file->public_dependency_count(); ++i) {
+    absl::string_view pub_base =
+        StripProtoExtension(file->public_dependency(i)->name());
+    h_printer.Emit({{"pub", pub_base}}, R"cc(
+#include "$pub$.cbor.h"  // IWYU pragma: export
+    )cc");
+  }
+
+  h_printer.Emit(R"cc(
+    namespace cbor {
+  )cc");
+
+  for (int i = 0; i < file->message_type_count(); ++i) {
+    GenerateMessageHeader(file->message_type(i), h_printer);
+  }
+
+  h_printer.Emit({{"guard", guard}}, R"cc(
+    }  // namespace cbor
+
+#endif  // $guard$
+  )cc");
+}
+
+void GenerateCc(const google::protobuf::FileDescriptor* file,
+                absl::string_view base_name,
+                google::protobuf::compiler::GeneratorContext* context) {
+  std::unique_ptr<google::protobuf::io::ZeroCopyOutputStream> cc_stream(
+      context->Open(absl::StrCat(base_name, ".cbor.cc")));
+  google::protobuf::io::Printer cc_printer(cc_stream.get());
+
+  cc_printer.Emit({{"base", base_name}}, R"cc(
+#include "$base$.cbor.h"
+  )cc");
+
+  if (file->message_type_count() > 0) {
+    cc_printer.Emit({{"base", base_name}}, R"cc(
+#include "$base$.pb.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "cbor/cbor.h"
+#include "cppbor/cppbor.h"
+    )cc");
+  }
+
+  absl::flat_hash_set<const google::protobuf::FileDescriptor*> used_deps;
+  for (int i = 0; i < file->message_type_count(); ++i) {
+    CollectUsedDeps(file->message_type(i), file, used_deps);
+  }
+
+  for (const auto* dep : used_deps) {
+    absl::string_view dep_base = StripProtoExtension(dep->name());
+    cc_printer.Emit({{"dep", dep_base}}, R"cc(
+#include "$dep$.cbor.h"
+    )cc");
+  }
+
+  cc_printer.Emit(R"cc(
+    namespace cbor {
+  )cc");
+
+  for (int i = 0; i < file->message_type_count(); ++i) {
+    GenerateToProtoBody(file->message_type(i), cc_printer);
+    GenerateFromProtoBody(file->message_type(i), cc_printer);
+  }
+
+  cc_printer.Emit(R"cc(
+    }  // namespace cbor
+  )cc");
+}
+
+}  // namespace
+
+bool CborGenerator::Generate(
+    const google::protobuf::FileDescriptor* file, const std::string& parameter,
+    google::protobuf::compiler::GeneratorContext* context,
+    std::string* error) const {
+  absl::string_view base_name = StripProtoExtension(file->name());
+
+  GenerateHeader(file, base_name, context);
+  GenerateCc(file, base_name, context);
+
+  return true;
+}
+
+}  // namespace cbor
diff --git a/cbor/cbor_generator.h b/cbor/cbor_generator.h
new file mode 100644
index 0000000..389ab10
--- /dev/null
+++ b/cbor/cbor_generator.h
@@ -0,0 +1,51 @@
+// 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_CBOR_CBOR_GENERATOR_H_
+#define THIRD_PARTY_CREDENTIO_CBOR_CBOR_GENERATOR_H_
+
+#include <cstdint>
+#include <string>
+
+#include "google/protobuf/compiler/code_generator.h"
+
+namespace cbor {
+
+class CborGenerator : public google::protobuf::compiler::CodeGenerator {
+ public:
+  CborGenerator() = default;
+  ~CborGenerator() override = default;
+
+  bool Generate(const google::protobuf::FileDescriptor* file,
+                const std::string& parameter,
+                google::protobuf::compiler::GeneratorContext* generator_context,
+                std::string* error) const override;
+
+  uint64_t GetSupportedFeatures() const override {
+    return FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS;
+  }
+
+  google::protobuf::Edition GetMinimumEdition() const override {
+    return google::protobuf::Edition::EDITION_PROTO2;
+  }
+
+  google::protobuf::Edition GetMaximumEdition() const override {
+    return google::protobuf::Edition::EDITION_2024;
+  }
+};
+
+}  // namespace cbor
+
+#endif  // THIRD_PARTY_CREDENTIO_CBOR_CBOR_GENERATOR_H_
diff --git a/cbor/cbor_generator_main.cc b/cbor/cbor_generator_main.cc
new file mode 100644
index 0000000..cdeb1a5
--- /dev/null
+++ b/cbor/cbor_generator_main.cc
@@ -0,0 +1,22 @@
+// 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 "cbor/cbor_generator.h"
+#include "google/protobuf/compiler/plugin.h"
+
+int main(int argc, char* argv[]) {
+  cbor::CborGenerator generator;
+  return google::protobuf::compiler::PluginMain(argc, argv, &generator);
+}
diff --git a/cbor/cbor_test.cc b/cbor/cbor_test.cc
new file mode 100644
index 0000000..e23aff2
--- /dev/null
+++ b/cbor/cbor_test.cc
@@ -0,0 +1,217 @@
+// 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 "cbor/cbor.h"
+
+#include <memory>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "cppbor/cppbor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace cbor {
+namespace {
+
+using ::testing::Eq;
+
+TEST(ItemViewTest, BoolType) {
+  auto item = std::make_unique<cppbor::Bool>(true);
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kBool);
+  auto val = view.GetBool();
+  ABSL_ASSERT_OK(val);
+  EXPECT_TRUE(*val);
+}
+
+TEST(ItemViewTest, FloatType) {
+  auto item = std::make_unique<cppbor::Float>(1.5f);
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kFloat);
+  auto val = view.GetFloat();
+  ABSL_ASSERT_OK(val);
+  EXPECT_FLOAT_EQ(*val, 1.5f);
+}
+
+TEST(ItemViewTest, DoubleType) {
+  auto item = std::make_unique<cppbor::Double>(2.5);
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kDouble);
+  auto val = view.GetDouble();
+  ABSL_ASSERT_OK(val);
+  EXPECT_DOUBLE_EQ(*val, 2.5);
+}
+
+TEST(ItemViewTest, Int64Type) {
+  auto item = std::make_unique<cppbor::Nint>(-42);
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kInt64);
+  auto val = view.GetInt64();
+  ABSL_ASSERT_OK(val);
+  EXPECT_EQ(*val, -42);
+}
+
+TEST(ItemViewTest, Uint64Type) {
+  auto item = std::make_unique<cppbor::Uint>(42);
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kUint64);
+  auto val = view.GetUint64();
+  ABSL_ASSERT_OK(val);
+  EXPECT_EQ(*val, 42);
+}
+
+TEST(ItemViewTest, StringType) {
+  auto item = std::make_unique<cppbor::Tstr>("hello");
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kString);
+  auto val = view.GetString();
+  ABSL_ASSERT_OK(val);
+  EXPECT_EQ(*val, "hello");
+}
+
+TEST(ItemViewTest, ByteStringType) {
+  auto item = std::make_unique<cppbor::Bstr>("world");
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kByteString);
+  auto val = view.GetByteString();
+  ABSL_ASSERT_OK(val);
+  EXPECT_EQ(*val, "world");
+}
+
+TEST(ItemViewTest, MapType) {
+  auto item = std::make_unique<cppbor::Map>();
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kMap);
+  auto val = view.GetMap();
+  ABSL_ASSERT_OK(val);
+}
+
+TEST(ItemViewTest, ArrayType) {
+  auto item = std::make_unique<cppbor::Array>();
+  ItemView view(item.get());
+  EXPECT_EQ(view.type(), ItemView::Type::kArray);
+  auto val = view.GetArray();
+  ABSL_ASSERT_OK(val);
+}
+
+TEST(ItemViewTest, IsNull) {
+  auto item = std::make_unique<cppbor::Null>();
+  ItemView view(item.get());
+  EXPECT_TRUE(view.IsNull());
+}
+
+TEST(ItemViewTest, Failures) {
+  auto item = std::make_unique<cppbor::Uint>(42);
+  ItemView view(item.get());
+
+  EXPECT_THAT(view.GetBool().status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(view.GetFloat().status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(view.GetDouble().status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(view.GetString().status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(view.GetByteString().status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(view.GetMap().status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(view.GetArray().status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(MapViewTest, GetMethods) {
+  auto map = std::make_unique<cppbor::Map>();
+  map->add("bool", cppbor::Bool(true));
+  map->add("int", cppbor::Uint(42));
+  map->add("string", cppbor::Tstr("value"));
+  map->add(42, cppbor::Uint(24));
+
+  MapView view(map.get());
+
+  auto bool_val = view.GetBool("bool");
+  ABSL_ASSERT_OK(bool_val);
+  EXPECT_TRUE(*bool_val);
+
+  auto int_val = view.GetUint64("int");
+  ABSL_ASSERT_OK(int_val);
+  EXPECT_EQ(*int_val, 42);
+
+  auto str_val = view.GetString("string");
+  ABSL_ASSERT_OK(str_val);
+  EXPECT_EQ(*str_val, "value");
+
+  auto int_key_val = view.GetInt64(42);
+  ABSL_ASSERT_OK(int_key_val);
+  EXPECT_EQ(*int_key_val, 24);
+}
+
+TEST(MapViewTest, GetOptional) {
+  auto map = std::make_unique<cppbor::Map>();
+  map->add("bool", cppbor::Bool(true));
+
+  MapView view(map.get());
+
+  EXPECT_TRUE(view.GetOptionalBool("bool").has_value());
+  EXPECT_TRUE(view.GetOptionalBool("bool").value());
+  EXPECT_FALSE(view.GetOptionalBool("nonexistent").has_value());
+}
+
+TEST(MapViewTest, Failures) {
+  auto map = std::make_unique<cppbor::Map>();
+  MapView view(map.get());
+
+  EXPECT_THAT(view.GetBool("nonexistent").status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ArrayViewTest, GetMethods) {
+  auto array = std::make_unique<cppbor::Array>();
+  array->add(cppbor::Bool(true));
+  array->add(cppbor::Uint(42));
+
+  ArrayView view(array.get());
+
+  EXPECT_EQ(view.size(), 2);
+  EXPECT_FALSE(view.empty());
+
+  auto bool_val = view.GetBool(0);
+  ABSL_ASSERT_OK(bool_val);
+  EXPECT_TRUE(*bool_val);
+
+  auto int_val = view.GetUint64(1);
+  ABSL_ASSERT_OK(int_val);
+  EXPECT_EQ(*int_val, 42);
+}
+
+TEST(ArrayViewTest, Failures) {
+  auto array = std::make_unique<cppbor::Array>();
+  ArrayView view(array.get());
+
+  EXPECT_THAT(view.GetBool(0).status().code(),
+              Eq(absl::StatusCode::kInvalidArgument));
+  EXPECT_EQ(view.value_type(0), ItemView::Type::kUnknown);
+  EXPECT_EQ(view.value_type(1), ItemView::Type::kUnknown);
+
+  array->add(cppbor::Uint(42));
+  EXPECT_EQ(view.value_type(0), ItemView::Type::kUint64);
+  EXPECT_EQ(view.value_type(1), ItemView::Type::kUnknown);
+}
+
+}  // namespace
+}  // namespace cbor
diff --git a/cbor/options.h b/cbor/options.h
new file mode 100644
index 0000000..d39b61a
--- /dev/null
+++ b/cbor/options.h
@@ -0,0 +1,29 @@
+// 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_CBOR_OPTIONS_H_
+#define THIRD_PARTY_CREDENTIO_CBOR_OPTIONS_H_
+
+namespace cbor {
+
+struct FromProtoOptions {
+  // If true, validation checks like is_required and disallow_empty are
+  // skipped when creating CBOR from Proto.
+  bool skip_validation = false;
+};
+
+}  // namespace cbor
+
+#endif  // THIRD_PARTY_CREDENTIO_CBOR_OPTIONS_H_
diff --git a/cbor/parse.cc b/cbor/parse.cc
new file mode 100644
index 0000000..bb3de64
--- /dev/null
+++ b/cbor/parse.cc
@@ -0,0 +1,46 @@
+// 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 "cbor/parse.h"
+
+#include <cstdint>
+#include <memory>
+#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"
+#include "cppbor/cppbor_parse.h"
+
+namespace cbor {
+
+absl::StatusOr<std::unique_ptr<ParseResult>> Parse(absl::string_view s) {
+  const uint8_t* data = reinterpret_cast<const uint8_t*>(s.data());
+  const uint8_t* end = data + s.size();
+  auto [item, new_position, error] = cppbor::parse(data, end);
+  if (!error.empty()) {
+    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));
+}
+
+}  // namespace cbor
diff --git a/cbor/parse.h b/cbor/parse.h
new file mode 100644
index 0000000..e5f0001
--- /dev/null
+++ b/cbor/parse.h
@@ -0,0 +1,31 @@
+// 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_CBOR_PARSE_H_
+#define THIRD_PARTY_CREDENTIO_CBOR_PARSE_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "cbor/cbor.h"
+
+namespace cbor {
+
+absl::StatusOr<std::unique_ptr<ParseResult>> Parse(absl::string_view s);
+
+}  // namespace cbor
+
+#endif  // THIRD_PARTY_CREDENTIO_CBOR_PARSE_H_
diff --git a/cbor/parse_test.cc b/cbor/parse_test.cc
new file mode 100644
index 0000000..78aafd5
--- /dev/null
+++ b/cbor/parse_test.cc
@@ -0,0 +1,495 @@
+// 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 "cbor/parse.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#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 "cbor/cbor.h"
+#include "cppbor/cppbor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "testing/cbor_utils.h"
+
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::cbor::FromJson;
+using ::testing::Eq;
+using ::testing::Gt;
+using ::testing::HasSubstr;
+using ::testing::IsFalse;
+using ::testing::IsTrue;
+
+TEST(ParseTest, Okay) {
+  auto cbor = FromJson(R"json({
+      "str": "Google",
+      "uint": 0,
+      "int": -1,
+      "nest_map": {"a": 1},
+      "empty_map": {},
+      "uint_array": [1, 2, 3],
+      "int_array": [-1, -2, -3],
+      "str_array": ["a", "b", "c"],
+      "array_of_map": [{"a": 1}, {"a": 2}],
+      "array_of_array": [[1, 2], [3, 4]],
+      "empty_array": [],
+      "null_array": [null]
+      })json");
+  auto value1 = cbor::Parse(cbor);
+  ASSERT_TRUE(value1.ok());
+  auto result = std::move(value1).value();
+  auto value2 = result->AsMap();
+  ASSERT_TRUE(value2.ok());
+  auto map = std::move(value2).value();
+
+  EXPECT_THAT(map.GetString("str"), IsOkAndHolds("Google"));
+  EXPECT_THAT(map.GetUint64("uint"), IsOkAndHolds(0));
+  EXPECT_THAT(map.GetInt64("int"), IsOkAndHolds(-1));
+
+  auto value3 = map.GetMap("nest_map");
+  ASSERT_TRUE(value3.ok());
+  auto nest_map = std::move(value3).value();
+  EXPECT_THAT(nest_map.GetInt64("a"), IsOkAndHolds(1));
+
+  auto value4 = map.GetMap("empty_map");
+  ASSERT_TRUE(value4.ok());
+  auto empty_map = std::move(value4).value();
+  EXPECT_THAT(empty_map.GetInt64("a"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+
+  auto value5 = map.GetArray("uint_array");
+  ASSERT_TRUE(value5.ok());
+  auto uint_array = std::move(value5).value();
+  EXPECT_THAT(uint_array.size(), Eq(3));
+  for (int i = 0; i < 3; ++i) {
+    EXPECT_THAT(uint_array.GetUint64(i), IsOkAndHolds(i + 1));
+  }
+
+  auto value6 = map.GetArray("int_array");
+  ASSERT_TRUE(value6.ok());
+  auto int_array = std::move(value6).value();
+  EXPECT_THAT(int_array.size(), Eq(3));
+  for (int i = 0; i < 3; ++i) {
+    EXPECT_THAT(int_array.GetInt64(i), IsOkAndHolds(-1 - i));
+  }
+
+  auto value7 = map.GetArray("str_array");
+  ASSERT_TRUE(value7.ok());
+  auto str_array = std::move(value7).value();
+  EXPECT_THAT(str_array.size(), Eq(3));
+  EXPECT_THAT(str_array.empty(), IsFalse());
+  for (int i = 0; i < 3; ++i) {
+    EXPECT_THAT(str_array.GetString(i), IsOkAndHolds(std::string(1, 'a' + i)));
+  }
+
+  auto value8 = map.GetArray("array_of_map");
+  ASSERT_TRUE(value8.ok());
+  auto array_of_map = std::move(value8).value();
+  EXPECT_THAT(array_of_map.size(), Eq(2));
+  auto value9 = array_of_map.GetMap(0);
+  ASSERT_TRUE(value9.ok());
+  auto array_of_map_0 = std::move(value9).value();
+  EXPECT_THAT(array_of_map_0.GetInt64("a"), IsOkAndHolds(1));
+  auto value10 = array_of_map.GetMap(1);
+  ASSERT_TRUE(value10.ok());
+  auto array_of_map_1 = std::move(value10).value();
+  EXPECT_THAT(array_of_map_1.GetInt64("a"), IsOkAndHolds(2));
+
+  auto value11 = map.GetArray("array_of_array");
+  ASSERT_TRUE(value11.ok());
+  auto array_of_array = std::move(value11).value();
+  EXPECT_THAT(array_of_array.size(), Eq(2));
+  auto value12 = array_of_array.GetArray(0);
+  ASSERT_TRUE(value12.ok());
+  auto array_of_array_0 = std::move(value12).value();
+  EXPECT_THAT(array_of_array_0.size(), Eq(2));
+  EXPECT_THAT(array_of_array_0.GetUint64(0), IsOkAndHolds(1));
+  EXPECT_THAT(array_of_array_0.GetUint64(1), IsOkAndHolds(2));
+  auto value13 = array_of_array.GetArray(1);
+  ASSERT_TRUE(value13.ok());
+  auto array_of_array_1 = std::move(value13).value();
+  EXPECT_THAT(array_of_array_1.size(), Eq(2));
+  EXPECT_THAT(array_of_array_1.GetUint64(0), IsOkAndHolds(3));
+  EXPECT_THAT(array_of_array_1.GetUint64(1), IsOkAndHolds(4));
+
+  auto value14 = map.GetArray("empty_array");
+  ASSERT_TRUE(value14.ok());
+  auto empty_array = std::move(value14).value();
+  EXPECT_THAT(empty_array.size(), Eq(0));
+  EXPECT_THAT(empty_array.empty(), IsTrue());
+
+  auto value15 = map.GetArray("null_array");
+  ASSERT_TRUE(value15.ok());
+  auto null_array = std::move(value15).value();
+  EXPECT_THAT(null_array.size(), Eq(1));
+  EXPECT_THAT(null_array.IsNull(0), IsOkAndHolds(IsTrue()));
+  EXPECT_THAT(str_array.IsNull(0), IsOkAndHolds(IsFalse()));
+}
+
+TEST(ParseTest, SemanticTag) {
+  cppbor::Map orig_map;
+  orig_map.add("when",
+               cppbor::SemanticTag(0, cppbor::Tstr("2026-05-19T00:00:00Z")));
+  orig_map.add("epoch", cppbor::SemanticTag(1, cppbor::Uint(1684454400)));
+  auto bytes = orig_map.encode();
+  absl::string_view bytes_view(reinterpret_cast<const char*>(bytes.data()),
+                               bytes.size());
+  auto value = cbor::Parse(bytes_view);
+  ASSERT_TRUE(value.ok());
+  auto result = std::move(value).value();
+  auto map_or = result->AsMap();
+  ASSERT_TRUE(map_or.ok());
+  auto map = std::move(map_or).value();
+  EXPECT_THAT(map.GetString("when"), IsOkAndHolds("2026-05-19T00:00:00Z"));
+  EXPECT_THAT(map.GetUint64("epoch"), IsOkAndHolds(1684454400));
+}
+
+TEST(ParseTest, OptionalString) {
+  auto cbor = FromJson(R"json({"str": "Google", "int": 0})json");
+  auto value16 = cbor::Parse(cbor);
+  ASSERT_TRUE(value16.ok());
+  auto result = std::move(value16).value();
+  auto value17 = result->AsMap();
+  ASSERT_TRUE(value17.ok());
+  auto map = std::move(value17).value();
+  EXPECT_THAT(map.GetOptionalString("str"), Eq("Google"));
+  EXPECT_THAT(map.GetOptionalString("not_found"), Eq(std::nullopt));
+  EXPECT_THAT(map.GetOptionalString("int"), Eq(std::nullopt));
+}
+
+TEST(ParseTest, OptionalMap) {
+  auto cbor = FromJson(
+      R"json({"not_map": "Google", "map":  {"a": 1}, "empty_map": {}})json");
+  auto value18 = cbor::Parse(cbor);
+  ASSERT_TRUE(value18.ok());
+  auto result = std::move(value18).value();
+  auto value19 = result->AsMap();
+  ASSERT_TRUE(value19.ok());
+  auto map = std::move(value19).value();
+  EXPECT_THAT(map.GetOptionalMap("not_map"), Eq(std::nullopt));
+  EXPECT_THAT(map.GetOptionalMap("not_found"), Eq(std::nullopt));
+  EXPECT_TRUE(map.GetOptionalMap("map").has_value());
+  EXPECT_TRUE(map.GetOptionalMap("empty_map").has_value());
+}
+
+TEST(ParseTest, OptionalArray) {
+  auto cbor = FromJson(
+      R"json({"not_array": "Google", "array": [1, 2], "empty_array": []})json");
+  auto value20 = cbor::Parse(cbor);
+  ASSERT_TRUE(value20.ok());
+  auto result = std::move(value20).value();
+  auto value21 = result->AsMap();
+  ASSERT_TRUE(value21.ok());
+  auto map = std::move(value21).value();
+  EXPECT_THAT(map.GetOptionalArray("not_array"), Eq(std::nullopt));
+  EXPECT_THAT(map.GetOptionalArray("not_found"), Eq(std::nullopt));
+  EXPECT_TRUE(map.GetOptionalArray("array").has_value());
+  EXPECT_TRUE(map.GetOptionalArray("empty_array").has_value());
+}
+
+TEST(ParseTest, OptionalUint64) {
+  auto cbor = FromJson(R"json({"not_uint": "Google", "uint": 1})json");
+  auto value22 = cbor::Parse(cbor);
+  ASSERT_TRUE(value22.ok());
+  auto result = std::move(value22).value();
+  auto value23 = result->AsMap();
+  ASSERT_TRUE(value23.ok());
+  auto map = std::move(value23).value();
+  EXPECT_THAT(map.GetOptionalUint64("not_uint"), Eq(std::nullopt));
+  EXPECT_THAT(map.GetOptionalUint64("not_found"), Eq(std::nullopt));
+  EXPECT_THAT(map.GetOptionalUint64("uint").value(), Eq(1));
+}
+
+TEST(ParseTest, UintMapKeys) {
+  // FromJson doesn't support creating integer-keyed maps -- would need
+  // special support like the "b64'blah" bytestring hack because JSON doesn't
+  // support integer map keys. So we'll create the map directly using cppbor.
+  cppbor::Map orig_map;
+  orig_map.add(cppbor::Uint(0), cppbor::Bstr("zero"));
+  orig_map.add(cppbor::Uint(3), cppbor::Nint(-333));
+  orig_map.add(cppbor::Uint(10),
+               cppbor::Array(cppbor::Tstr("a"), cppbor::Nint(-12)));
+  auto bytes = orig_map.encode();
+  absl::string_view bytes_view(reinterpret_cast<char*>(bytes.data()),
+                               bytes.size());
+  auto value24 = cbor::Parse(bytes_view);
+  ASSERT_TRUE(value24.ok());
+  auto result = std::move(value24).value();
+  auto value25 = result->AsMap();
+  ASSERT_TRUE(value25.ok());
+  auto map = std::move(value25).value();
+  EXPECT_THAT(map.GetByteString(0), IsOkAndHolds("zero"));
+  EXPECT_THAT(map.GetInt64(3), IsOkAndHolds(-333));
+  auto value26 = map.GetArray(10);
+  ASSERT_TRUE(value26.ok());
+  auto arr = std::move(value26).value();
+  EXPECT_THAT(arr.size(), Eq(2));
+  EXPECT_THAT(arr.GetString(0), IsOkAndHolds("a"));
+  EXPECT_THAT(arr.GetInt64(1), IsOkAndHolds(-12));
+}
+
+TEST(ParseTest, ErrorResultNotAMap) {
+  auto cbor = FromJson(R"json([1, 2, 3])json");
+  auto value27 = cbor::Parse(cbor);
+  ASSERT_TRUE(value27.ok());
+  auto result = std::move(value27).value();
+  EXPECT_THAT(result->AsMap(),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR parsed result is not a map")));
+}
+
+TEST(ParseTest, ErrorResultNotAnArray) {
+  auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
+  auto value28 = cbor::Parse(cbor);
+  ASSERT_TRUE(value28.ok());
+  auto result = std::move(value28).value();
+  EXPECT_THAT(result->AsArray(),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR parsed result is not an array")));
+}
+
+TEST(ParseTest, ErrorMapGetStringNotAString) {
+  auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
+  auto value29 = cbor::Parse(cbor);
+  ASSERT_TRUE(value29.ok());
+  auto result = std::move(value29).value();
+  auto value30 = result->AsMap();
+  ASSERT_TRUE(value30.ok());
+  auto map = std::move(value30).value();
+  EXPECT_THAT(map.GetString("schema"),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR item is not a string")));
+}
+
+TEST(ParseTest, ErrorMapGetInt64NotAnInt) {
+  auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
+  auto value31 = cbor::Parse(cbor);
+  ASSERT_TRUE(value31.ok());
+  auto result = std::move(value31).value();
+  auto value32 = result->AsMap();
+  ASSERT_TRUE(value32.ok());
+  auto map = std::move(value32).value();
+  EXPECT_THAT(map.GetInt64("vendor"),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR item is not an int")));
+}
+
+TEST(ParseTest, ErrorMapGetUint64NotAUint) {
+  auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
+  auto value33 = cbor::Parse(cbor);
+  ASSERT_TRUE(value33.ok());
+  auto result = std::move(value33).value();
+  auto value34 = result->AsMap();
+  ASSERT_TRUE(value34.ok());
+  auto map = std::move(value34).value();
+  EXPECT_THAT(map.GetUint64("vendor"),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR item is not an uint")));
+}
+
+TEST(ParseTest, ErrorNotAUintInArray) {
+  auto cbor = FromJson(R"json(["vendor", "Google"])json");
+  auto value35 = cbor::Parse(cbor);
+  ASSERT_TRUE(value35.ok());
+  auto result = std::move(value35).value();
+  auto value36 = result->AsArray();
+  ASSERT_TRUE(value36.ok());
+  auto array = std::move(value36).value();
+  EXPECT_THAT(array.GetUint64(0),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR item is not an uint")));
+}
+
+TEST(ParseTest, ErrorNotAMapInArray) {
+  auto cbor = FromJson(R"json([{"vendor": "Google"}, "schema"])json");
+  auto value37 = cbor::Parse(cbor);
+  ASSERT_TRUE(value37.ok());
+  auto result = std::move(value37).value();
+  auto value38 = result->AsArray();
+  ASSERT_TRUE(value38.ok());
+  auto array = std::move(value38).value();
+  EXPECT_THAT(array.GetMap(1), StatusIs(absl::StatusCode::kInvalidArgument,
+                                        HasSubstr("CBOR item is not a map")));
+}
+
+TEST(ParseTest, ErrorKeyNotFound) {
+  auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
+  auto value39 = cbor::Parse(cbor);
+  ASSERT_TRUE(value39.ok());
+  auto result = std::move(value39).value();
+  auto value40 = result->AsMap();
+  ASSERT_TRUE(value40.ok());
+  auto map = std::move(value40).value();
+  EXPECT_THAT(
+      map.GetString("not_found"),
+      StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("Key not found")));
+}
+
+TEST(ParseTest, DuplicateKey) {
+  std::string cbor;
+  // This is a CBOR map {"color": "red", "color": "green"}.
+  ASSERT_TRUE(absl::HexStringToBytes(
+      "a265636f6c6f726372656465636f6c6f7265677265656e", &cbor));
+  auto value41 = cbor::Parse(cbor);
+  ASSERT_TRUE(value41.ok());
+  auto result = std::move(value41).value();
+  auto value42 = result->AsMap();
+  ASSERT_TRUE(value42.ok());
+  auto map = std::move(value42).value();
+  EXPECT_THAT(map.GetString("color"), "red");
+}
+
+TEST(ParseTest, ErrorIndexOutOfRange) {
+  auto cbor = FromJson(R"json([0, 1, 2])json");
+  auto value43 = cbor::Parse(cbor);
+  ASSERT_TRUE(value43.ok());
+  auto result = std::move(value43).value();
+  auto value44 = result->AsArray();
+  ASSERT_TRUE(value44.ok());
+  auto array = std::move(value44).value();
+  EXPECT_THAT(array.GetUint64(3), StatusIs(absl::StatusCode::kInvalidArgument,
+                                           HasSubstr("Index out of range")));
+}
+
+TEST(ParseTest, ErrorFailedToParse) {
+  auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
+  EXPECT_THAT(cbor::Parse(cbor.substr(0, 10)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("CBOR parsing failed")));
+}
+
+TEST(ParseTest, ErrorTrailingBytes) {
+  auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
+  EXPECT_THAT(cbor::Parse(cbor + "trailing bytes"),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Trailing bytes")));
+}
+
+void CheckMap(const cbor::MapView& map, absl::string_view str_key,
+              uint32_t int_key);
+
+void CheckArray(const cbor::ArrayView& array, absl::string_view str_key,
+                uint32_t int_key) {
+  if (array.empty()) {
+    ASSERT_THAT(array.size(), Eq(0));
+    ASSERT_THAT(array.value_type(0), Eq(cbor::ItemView::Type::kUnknown));
+  } else {
+    ASSERT_THAT(array.size(), Gt(0));
+  }
+  for (auto i = 0; i < array.size(); ++i) {
+    SCOPED_TRACE(absl::StrCat(i));
+    switch (array.value_type(i)) {
+      case cbor::ItemView::Type::kUnknown:
+        EXPECT_THAT(array.GetBool(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        EXPECT_THAT(array.GetFloat(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        EXPECT_THAT(array.GetDouble(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        EXPECT_THAT(array.GetString(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        EXPECT_THAT(array.GetByteString(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        EXPECT_THAT(array.GetInt64(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        EXPECT_THAT(array.GetUint64(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        EXPECT_THAT(array.GetMap(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        EXPECT_THAT(array.GetArray(i),
+                    StatusIs(absl::StatusCode::kInvalidArgument));
+        break;
+      case cbor::ItemView::Type::kBool:
+        EXPECT_THAT(array.GetBool(i), StatusIs(absl::StatusCode::kOk));
+        break;
+      case cbor::ItemView::Type::kFloat:
+        EXPECT_THAT(array.GetFloat(i), StatusIs(absl::StatusCode::kOk));
+        break;
+      case cbor::ItemView::Type::kDouble:
+        EXPECT_THAT(array.GetDouble(i), StatusIs(absl::StatusCode::kOk));
+        break;
+      case cbor::ItemView::Type::kString: {
+        auto value45 = array.GetString(i);
+        ASSERT_TRUE(value45.ok());
+        absl::string_view str = std::move(value45).value();
+        std::string force_copy(str);
+        break;
+      }
+      case cbor::ItemView::Type::kByteString: {
+        auto value46 = array.GetByteString(i);
+        ASSERT_TRUE(value46.ok());
+        absl::string_view str = std::move(value46).value();
+        std::string force_copy(str);
+        break;
+      }
+      case cbor::ItemView::Type::kInt64:
+        EXPECT_THAT(array.GetInt64(i), StatusIs(absl::StatusCode::kOk));
+        break;
+      case cbor::ItemView::Type::kUint64:
+        EXPECT_THAT(array.GetUint64(i), StatusIs(absl::StatusCode::kOk));
+        break;
+      case cbor::ItemView::Type::kMap: {
+        auto value47 = array.GetMap(i);
+        ASSERT_TRUE(value47.ok());
+        auto child = std::move(value47).value();
+        CheckMap(child, str_key, int_key);
+        break;
+      }
+      case cbor::ItemView::Type::kArray: {
+        auto value48 = array.GetArray(i);
+        ASSERT_TRUE(value48.ok());
+        auto child = std::move(value48).value();
+        CheckArray(child, str_key, int_key);
+        break;
+      }
+    }
+  }
+}
+
+void CheckMap(const cbor::MapView& map, absl::string_view str_key,
+              uint32_t int_key) {
+  {
+    // Bool:
+    const auto status_bool = map.GetBool(str_key);
+    const auto opt_bool = map.GetOptionalBool(str_key);
+    ASSERT_THAT(status_bool.ok(), Eq(opt_bool.has_value()));
+    if (status_bool.ok()) {
+      EXPECT_THAT(*status_bool, Eq(*opt_bool));
+    }
+  }
+  {
+    // String:
+    const auto status_str = map.GetString(str_key);
+    const auto opt_str = map.GetOptionalString(str_key);
+    ASSERT_THAT(status_str.ok(), Eq(opt_str.has_value()));
+    if (status_str.ok()) {
+      EXPECT_THAT(*status_str, Eq(*opt_str));
+    }
+  }
+}
+
+}  // namespace
diff --git a/cbor/tags.proto b/cbor/tags.proto
new file mode 100644
index 0000000..2d62f00
--- /dev/null
+++ b/cbor/tags.proto
@@ -0,0 +1,72 @@
+// 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.
+//
+
+edition = "2024";
+
+package cbor;
+
+import "google/protobuf/descriptor.proto";
+
+// Options for mapping fields from CBOR (CDDL) to Protobuf.
+message Tags {
+  enum Type {
+    TYPE_UNSPECIFIED = 0;
+    TYPE_UINT = 1;
+    TYPE_NINT = 2;
+    TYPE_BYTE_STRING = 3;
+    TYPE_TEXT_STRING = 4;
+    TYPE_ARRAY = 5;
+    TYPE_MAP = 6;
+    TYPE_TAG = 7;
+    TYPE_SIMPLE = 8;
+  }
+
+  enum Mode {
+    // Field is read from CBOR and written to CBOR (default).
+    MODE_UNSPECIFIED = 0;
+
+    // Field is only read from CBOR (ignored when writing).
+    MODE_READ_ONLY = 1;
+
+    // Field is only written to CBOR (ignored when parsing).
+    MODE_WRITE_ONLY = 2;
+
+    // Field is ignored in both reading and writing.
+    MODE_SKIP = 3;
+  }
+
+  // The name of the field in the CBOR (CDDL) spec.
+  // If not set, the field name is same as the proto field name.
+  string name = 1;
+
+  // If set, the field is required.
+  bool is_required = 2;
+
+  // If set, the field cannot be empty.
+  // Should only be used for repeated fields.
+  bool disallow_empty = 3;
+
+  // The CBOR type of the field.
+  // If not set, the type is inferred from the Protobuf type.
+  Type type = 4;
+
+  // The access mode of the field.
+  // If not set, the default is read and write (MODE_UNSPECIFIED).
+  Mode mode = 5;
+}
+
+extend google.protobuf.FieldOptions {
+  Tags tags = 8953939;
+}
diff --git a/cbor/test/BUILD b/cbor/test/BUILD
new file mode 100644
index 0000000..1ff1ff3
--- /dev/null
+++ b/cbor/test/BUILD
@@ -0,0 +1,123 @@
+# 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.
+#
+
+load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library")
+load("@protobuf//bazel:proto_library.bzl", "proto_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+load("//cbor:cbor_codegen.bzl", "cc_cbor_proto_library")
+
+package(default_visibility = ["//visibility:public"])
+
+proto_library(
+    name = "external_proto",
+    srcs = ["external.proto"],
+)
+
+proto_library(
+    name = "external_skip_new_proto",
+    srcs = ["external_skip_new.proto"],
+)
+
+cc_proto_library(
+    name = "external_skip_new_cc_proto",
+    deps = [":external_skip_new_proto"],
+)
+
+proto_library(
+    name = "external_skip_proto",
+    srcs = ["external_skip.proto"],
+    exports = [":external_skip_new_proto"],
+    deps = [
+        ":external_skip_new_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "external_skip_cc_proto",
+    deps = [":external_skip_proto"],
+)
+
+cc_proto_library(
+    name = "external_cc_proto",
+    deps = [":external_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "external_cbor_proto",
+    srcs = [":external_proto"],
+    deps = [":external_cc_proto"],
+)
+
+proto_library(
+    name = "migrated_proto",
+    srcs = ["migrated.proto"],
+    exports = ["//cbor/test/subdir:imported_public_proto"],
+    deps = ["//cbor/test/subdir:imported_public_proto"],
+)
+
+cc_proto_library(
+    name = "migrated_cc_proto",
+    deps = [":migrated_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "migrated_cbor_proto",
+    srcs = [":migrated_proto"],
+    deps = [
+        ":migrated_cc_proto",
+        "//cbor/test/subdir:imported_public_cbor_proto",
+    ],
+)
+
+proto_library(
+    name = "test_proto",
+    srcs = ["test.proto"],
+    deps = [
+        ":external_proto",
+        ":external_skip_proto",
+        "//cbor:tags_proto",
+        "@protobuf//:timestamp_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "test_cc_proto",
+    deps = [":test_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "test_cbor_proto",
+    srcs = [":test_proto"],
+    deps = [
+        ":test_cc_proto",
+    ],
+)
+
+cc_test(
+    name = "cbor_generator_test",
+    srcs = ["cbor_generator_test.cc"],
+    deps = [
+        ":external_cc_proto",
+        ":migrated_cbor_proto",
+        ":test_cbor_proto",
+        ":test_cc_proto",
+        "//cbor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@libcppbor",
+    ],
+)
diff --git a/cbor/test/cbor_generator_test.cc b/cbor/test/cbor_generator_test.cc
new file mode 100644
index 0000000..1779716
--- /dev/null
+++ b/cbor/test/cbor_generator_test.cc
@@ -0,0 +1,409 @@
+// 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 <memory>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "cbor/cbor.h"
+#include "cbor/options.h"
+#include "cbor/test/external.pb.h"
+#include "cbor/test/migrated.cbor.h"  // IWYU pragma: keep
+#include "cbor/test/test.cbor.h"
+#include "cbor/test/test.pb.h"
+#include "cppbor/cppbor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace cbor_test {
+namespace {
+
+using ::testing::Eq;
+
+TEST(CborGeneratorTest, RoundTrip) {
+  TestMessage msg;
+  msg.set_name("hello");
+  msg.set_version(1);
+
+  // Serialize to CBOR map
+  auto map_or = cbor::FromProto(msg);
+  ABSL_ASSERT_OK(map_or);
+
+  auto map = std::move(map_or.value());
+  ASSERT_NE(map, nullptr);
+
+  // Deserialize from CBOR map
+  TestMessage parsed_msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &parsed_msg);
+  ABSL_ASSERT_OK(status);
+
+  EXPECT_EQ(parsed_msg.name(), "hello");
+  EXPECT_EQ(parsed_msg.version(), 1);
+}
+
+TEST(CborGeneratorTest, ComplexRoundTrip) {
+  ComplexMessage msg;
+  msg.set_int32_field(10);
+  msg.set_int64_field(20);
+  msg.set_uint32_field(30);
+  msg.set_uint64_field(40);
+  msg.set_string_field("test_string");
+  msg.set_bytes_field("test_bytes");
+  msg.set_bool_field(true);
+  msg.set_float_field(1.5f);
+  msg.set_double_field(2.5);
+
+  msg.add_repeated_int32(100);
+  msg.add_repeated_int32(200);
+  msg.add_repeated_string("item1");
+  msg.add_repeated_string("item2");
+
+  msg.mutable_nested()->set_value("nested_val");
+  msg.add_repeated_nested()->set_value("rn1");
+  msg.add_repeated_nested()->set_value("rn2");
+
+  msg.add_repeated_bytes("b1");
+  msg.add_repeated_bytes("b2");
+
+  msg.add_repeated_string_array("s1");
+  msg.add_repeated_string_array("s2");
+
+  msg.add_repeated_int64(2000000000L);
+  msg.add_repeated_int64(3000000000L);
+
+  msg.add_repeated_uint32(300);
+  msg.add_repeated_uint32(400);
+
+  msg.add_repeated_uint64(4000000000L);
+  msg.add_repeated_uint64(5000000000L);
+
+  msg.add_repeated_bool(true);
+  msg.add_repeated_bool(false);
+
+  msg.add_repeated_float(1.5f);
+  msg.add_repeated_float(2.5f);
+
+  msg.add_repeated_double(3.5);
+  msg.add_repeated_double(4.5);
+
+  msg.set_read_only_field("ro");
+  msg.set_write_only_field("wo");
+  msg.set_skip_field("skip");
+  msg.mutable_external_field()->set_external_value("ext_val");
+
+  // Serialize to CBOR map
+  auto map_or = cbor::FromProto(msg);
+  ABSL_ASSERT_OK(map_or);
+
+  auto map = std::move(map_or.value());
+  ASSERT_NE(map, nullptr);
+
+  // Deserialize from CBOR map
+  ComplexMessage parsed_msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &parsed_msg);
+  ABSL_ASSERT_OK(status);
+
+  EXPECT_EQ(parsed_msg.int32_field(), 10);
+  EXPECT_EQ(parsed_msg.int64_field(), 20);
+  EXPECT_EQ(parsed_msg.uint32_field(), 30);
+  EXPECT_EQ(parsed_msg.uint64_field(), 40);
+  EXPECT_EQ(parsed_msg.string_field(), "test_string");
+  EXPECT_EQ(parsed_msg.bytes_field(), "test_bytes");
+  EXPECT_EQ(parsed_msg.bool_field(), true);
+  EXPECT_FLOAT_EQ(parsed_msg.float_field(), 1.5f);
+  EXPECT_DOUBLE_EQ(parsed_msg.double_field(), 2.5);
+
+  ASSERT_EQ(parsed_msg.repeated_int32_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_int32(0), 100);
+  EXPECT_EQ(parsed_msg.repeated_int32(1), 200);
+
+  ASSERT_EQ(parsed_msg.repeated_string_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_string(0), "item1");
+  EXPECT_EQ(parsed_msg.repeated_string(1), "item2");
+
+  EXPECT_EQ(parsed_msg.nested().value(), "nested_val");
+
+  ASSERT_EQ(parsed_msg.repeated_nested_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_nested(0).value(), "rn1");
+  EXPECT_EQ(parsed_msg.repeated_nested(1).value(), "rn2");
+
+  ASSERT_EQ(parsed_msg.repeated_bytes_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_bytes(0), "b1");
+  EXPECT_EQ(parsed_msg.repeated_bytes(1), "b2");
+
+  ASSERT_EQ(parsed_msg.repeated_string_array_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_string_array(0), "s1");
+  EXPECT_EQ(parsed_msg.repeated_string_array(1), "s2");
+
+  ASSERT_EQ(parsed_msg.repeated_int64_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_int64(0), 2000000000L);
+  EXPECT_EQ(parsed_msg.repeated_int64(1), 3000000000L);
+
+  ASSERT_EQ(parsed_msg.repeated_uint32_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_uint32(0), 300);
+  EXPECT_EQ(parsed_msg.repeated_uint32(1), 400);
+
+  ASSERT_EQ(parsed_msg.repeated_uint64_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_uint64(0), 4000000000L);
+  EXPECT_EQ(parsed_msg.repeated_uint64(1), 5000000000L);
+
+  ASSERT_EQ(parsed_msg.repeated_bool_size(), 2);
+  EXPECT_EQ(parsed_msg.repeated_bool(0), true);
+  EXPECT_EQ(parsed_msg.repeated_bool(1), false);
+
+  ASSERT_EQ(parsed_msg.repeated_float_size(), 2);
+  EXPECT_FLOAT_EQ(parsed_msg.repeated_float(0), 1.5f);
+  EXPECT_FLOAT_EQ(parsed_msg.repeated_float(1), 2.5f);
+
+  ASSERT_EQ(parsed_msg.repeated_double_size(), 2);
+  EXPECT_DOUBLE_EQ(parsed_msg.repeated_double(0), 3.5);
+  EXPECT_DOUBLE_EQ(parsed_msg.repeated_double(1), 4.5);
+
+  EXPECT_EQ(parsed_msg.read_only_field(), "");
+  EXPECT_EQ(parsed_msg.write_only_field(), "");
+  EXPECT_EQ(parsed_msg.skip_field(), "");
+  EXPECT_EQ(parsed_msg.external_field().external_value(), "ext_val");
+}
+
+TEST(CborGeneratorTest, RequiredFieldMissing) {
+  auto map = std::make_unique<cppbor::Map>();
+
+  RequiredMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  EXPECT_THAT(status.code(), Eq(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(CborGeneratorTest, RequiredFieldPresent) {
+  auto map = std::make_unique<cppbor::Map>();
+  map->add("RequiredField", cppbor::Tstr("value"));
+
+  RequiredMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  ABSL_EXPECT_OK(status);
+  EXPECT_EQ(msg.required_field(), "value");
+}
+
+TEST(CborGeneratorTest, RequiredRepeatedFieldEmpty) {
+  RequiredRepeatedMessage msg;
+
+  auto map_or = cbor::FromProto(msg);
+  ABSL_ASSERT_OK(map_or);
+
+  auto map = std::move(map_or.value());
+  ASSERT_NE(map, nullptr);
+
+  cbor::MapView map_view(map.get());
+  auto array_view = map_view.GetOptionalArray("RequiredRepeatedField");
+  ASSERT_TRUE(array_view.has_value());
+  EXPECT_EQ(array_view->size(), 0);
+}
+
+TEST(CborGeneratorTest, RequiredDisallowEmptyPresent) {
+  auto map = std::make_unique<cppbor::Map>();
+  auto array = std::make_unique<cppbor::Array>();
+  array->add(cppbor::Tstr("item"));
+  map->add("RequiredNonEmptyList", std::move(array));
+
+  RequiredDisallowEmptyMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  ABSL_EXPECT_OK(status);
+  ASSERT_EQ(msg.required_non_empty_list_size(), 1);
+  EXPECT_EQ(msg.required_non_empty_list(0), "item");
+}
+
+TEST(CborGeneratorTest, RequiredDisallowEmptyEmpty) {
+  auto map = std::make_unique<cppbor::Map>();
+  auto array = std::make_unique<cppbor::Array>();
+  map->add("RequiredNonEmptyList", std::move(array));
+
+  RequiredDisallowEmptyMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  EXPECT_THAT(status.code(), Eq(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(CborGeneratorTest, RequiredDisallowEmptyMissing) {
+  auto map = std::make_unique<cppbor::Map>();
+
+  RequiredDisallowEmptyMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  EXPECT_THAT(status.code(), Eq(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(CborGeneratorTest, OptionalDisallowEmptyPresent) {
+  auto map = std::make_unique<cppbor::Map>();
+  auto array = std::make_unique<cppbor::Array>();
+  array->add(cppbor::Tstr("item"));
+  map->add("OptionalNonEmptyList", std::move(array));
+
+  OptionalDisallowEmptyMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  ABSL_EXPECT_OK(status);
+  ASSERT_EQ(msg.optional_non_empty_list_size(), 1);
+  EXPECT_EQ(msg.optional_non_empty_list(0), "item");
+}
+
+TEST(CborGeneratorTest, OptionalDisallowEmptyEmpty) {
+  auto map = std::make_unique<cppbor::Map>();
+  auto array = std::make_unique<cppbor::Array>();
+  map->add("OptionalNonEmptyList", std::move(array));
+
+  OptionalDisallowEmptyMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  EXPECT_THAT(status.code(), Eq(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(CborGeneratorTest, OptionalDisallowEmptyMissing) {
+  auto map = std::make_unique<cppbor::Map>();
+
+  OptionalDisallowEmptyMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  ABSL_EXPECT_OK(status);
+  // Proto2 does not distinguish between empty and missing, so this is 0.
+  EXPECT_EQ(msg.optional_non_empty_list_size(), 0);
+}
+
+TEST(CborGeneratorTest, TypeOverrideTextString) {
+  auto map = std::make_unique<cppbor::Map>();
+  map->add("TextAsBytes", cppbor::Tstr("text_value"));
+
+  TypeOverrideMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  ABSL_EXPECT_OK(status);
+  EXPECT_EQ(msg.text_as_bytes(), "text_value");
+}
+
+TEST(CborGeneratorTest, TypeOverrideByteString) {
+  auto map = std::make_unique<cppbor::Map>();
+  map->add("BytesAsText", cppbor::Bstr("byte_value"));
+
+  TypeOverrideMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  ABSL_EXPECT_OK(status);
+  EXPECT_EQ(msg.bytes_as_text(), "byte_value");
+}
+
+TEST(CborGeneratorTest, TypeOverrideUint) {
+  auto map = std::make_unique<cppbor::Map>();
+  map->add("UintAsInt", cppbor::Uint(42));
+
+  TypeOverrideMessage msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &msg);
+  ABSL_EXPECT_OK(status);
+  EXPECT_EQ(msg.uint_as_int(), 42);
+}
+
+TEST(CborGeneratorTest, SkipValidationMissingRequired) {
+  RequiredMessage msg;
+  cbor::FromProtoOptions options;
+  options.skip_validation = true;
+  auto map_or = cbor::FromProto(msg, options);
+  ABSL_EXPECT_OK(map_or);
+}
+
+TEST(CborGeneratorTest, SkipValidationEmptyRequiredDisallowEmpty) {
+  RequiredDisallowEmptyMessage msg;
+  cbor::FromProtoOptions options;
+  options.skip_validation = true;
+  auto map_or = cbor::FromProto(msg, options);
+  ABSL_EXPECT_OK(map_or);
+}
+
+TEST(CborGeneratorTest, SkipValidationEmptyOptionalDisallowEmpty) {
+  // Proto2 does not distinguish between empty and missing, so an empty list in
+  // the proto should be considered "not present" when converting to cbor.
+  OptionalDisallowEmptyMessage msg;
+  cbor::FromProtoOptions options;
+  options.skip_validation = true;
+  auto map_or = cbor::FromProto(msg, options);
+  ABSL_ASSERT_OK(map_or);
+  auto map = std::move(map_or.value());
+  cbor::MapView map_view(map.get());
+  EXPECT_FALSE(map_view.GetOptionalArray("OptionalNonEmptyList").has_value());
+}
+
+TEST(CborGeneratorTest, FromProtoMissingRequired) {
+  RequiredMessage msg;
+  auto map_or = cbor::FromProto(msg);
+  EXPECT_FALSE(map_or.ok());
+  EXPECT_EQ(map_or.status().code(), absl::StatusCode::kInvalidArgument);
+}
+
+TEST(CborGeneratorTest, FromProtoEmptyRequiredDisallowEmpty) {
+  RequiredDisallowEmptyMessage msg;
+  auto map_or = cbor::FromProto(msg);
+  EXPECT_FALSE(map_or.ok());
+  EXPECT_EQ(map_or.status().code(), absl::StatusCode::kInvalidArgument);
+}
+
+TEST(CborGeneratorTest, FromProtoEmptyOptionalDisallowEmpty) {
+  OptionalDisallowEmptyMessage msg;
+  auto map_or = cbor::FromProto(msg);
+  ABSL_ASSERT_OK(map_or);
+  auto map = std::move(map_or.value());
+  cbor::MapView map_view(map.get());
+  EXPECT_FALSE(map_view.GetOptionalArray("OptionalNonEmptyList").has_value());
+}
+
+TEST(CborGeneratorTest, FromProtoNonEmptyOptionalDisallowEmpty) {
+  OptionalDisallowEmptyMessage msg;
+  msg.add_optional_non_empty_list("item");
+
+  auto map_or = cbor::FromProto(msg);
+  ABSL_ASSERT_OK(map_or);
+  auto map = std::move(map_or.value());
+  cbor::MapView map_view(map.get());
+  auto list_opt = map_view.GetOptionalArray("OptionalNonEmptyList");
+  ASSERT_TRUE(list_opt.has_value());
+  EXPECT_EQ(list_opt->size(), 1);
+  auto first_item_or = list_opt->GetString(0);
+  ABSL_ASSERT_OK(first_item_or);
+  EXPECT_EQ(first_item_or.value(), "item");
+}
+
+TEST(CborGeneratorTest, ImportedPublicRoundTrip) {
+  ImportedPublicMessage msg;
+  msg.set_public_value("imported_val");
+
+  auto map_or = cbor::FromProto(msg);
+  ABSL_ASSERT_OK(map_or);
+
+  auto map = std::move(map_or.value());
+  ASSERT_NE(map, nullptr);
+
+  ImportedPublicMessage parsed_msg;
+  cbor::MapView map_view(map.get());
+  auto status = cbor::ToProto(map_view, &parsed_msg);
+  ABSL_ASSERT_OK(status);
+
+  EXPECT_EQ(parsed_msg.public_value(), "imported_val");
+}
+
+}  // namespace
+}  // namespace cbor_test
diff --git a/cbor/test/external.proto b/cbor/test/external.proto
new file mode 100644
index 0000000..050aacd
--- /dev/null
+++ b/cbor/test/external.proto
@@ -0,0 +1,22 @@
+// 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.
+//
+
+edition = "2024";
+
+package cbor_test;
+
+message ExternalMessage {
+  string external_value = 1;
+}
diff --git a/cbor/test/external_skip.proto b/cbor/test/external_skip.proto
new file mode 100644
index 0000000..89f3b31
--- /dev/null
+++ b/cbor/test/external_skip.proto
@@ -0,0 +1,20 @@
+// 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.
+//
+
+edition = "2024";
+
+package cbor_test;
+
+import public "cbor/test/external_skip_new.proto";
diff --git a/cbor/test/external_skip_new.proto b/cbor/test/external_skip_new.proto
new file mode 100644
index 0000000..e14cbdf
--- /dev/null
+++ b/cbor/test/external_skip_new.proto
@@ -0,0 +1,22 @@
+// 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.
+//
+
+edition = "2024";
+
+package cbor_test;
+
+message ExternalSkipMessage {
+  string skip_value = 1;
+}
diff --git a/cbor/test/migrated.proto b/cbor/test/migrated.proto
new file mode 100644
index 0000000..ace68ec
--- /dev/null
+++ b/cbor/test/migrated.proto
@@ -0,0 +1,20 @@
+// 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.
+//
+
+edition = "2024";
+
+package cbor_test;
+
+import public "cbor/test/subdir/imported_public.proto";
diff --git a/cbor/test/subdir/BUILD b/cbor/test/subdir/BUILD
new file mode 100644
index 0000000..b561d49
--- /dev/null
+++ b/cbor/test/subdir/BUILD
@@ -0,0 +1,36 @@
+# 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.
+#
+
+load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library")
+load("@protobuf//bazel:proto_library.bzl", "proto_library")
+load("//cbor:cbor_codegen.bzl", "cc_cbor_proto_library")
+
+package(default_visibility = ["//visibility:public"])
+
+proto_library(
+    name = "imported_public_proto",
+    srcs = ["imported_public.proto"],
+)
+
+cc_proto_library(
+    name = "imported_public_cc_proto",
+    deps = [":imported_public_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "imported_public_cbor_proto",
+    srcs = [":imported_public_proto"],
+    deps = [":imported_public_cc_proto"],
+)
diff --git a/cbor/test/subdir/imported_public.proto b/cbor/test/subdir/imported_public.proto
new file mode 100644
index 0000000..3b79b47
--- /dev/null
+++ b/cbor/test/subdir/imported_public.proto
@@ -0,0 +1,22 @@
+// 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.
+//
+
+edition = "2024";
+
+package cbor_test;
+
+message ImportedPublicMessage {
+  string public_value = 1;
+}
diff --git a/cbor/test/test.proto b/cbor/test/test.proto
new file mode 100644
index 0000000..ff51b3d
--- /dev/null
+++ b/cbor/test/test.proto
@@ -0,0 +1,109 @@
+// 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.
+//
+
+edition = "2024";
+
+package cbor_test;
+
+import "cbor/tags.proto";
+import "cbor/test/external.proto";
+import "cbor/test/external_skip.proto";
+import "google/protobuf/timestamp.proto";
+
+message TestMessage {
+  string name = 1 [(cbor.tags) = { name: "Name" }];
+  int32 version = 2 [(cbor.tags) = { name: "Version" }];
+}
+
+message RequiredMessage {
+  string required_field = 1
+      [(cbor.tags) = { name: "RequiredField", is_required: true }];
+}
+
+message RequiredDisallowEmptyMessage {
+  repeated string required_non_empty_list = 1 [(cbor.tags) = {
+    name: "RequiredNonEmptyList",
+    is_required: true,
+    disallow_empty: true
+  }];
+}
+
+message OptionalDisallowEmptyMessage {
+  repeated string optional_non_empty_list = 1
+      [(cbor.tags) = { name: "OptionalNonEmptyList", disallow_empty: true }];
+}
+
+message RequiredRepeatedMessage {
+  repeated string required_repeated_field = 1
+      [(cbor.tags) = { name: "RequiredRepeatedField", is_required: true }];
+  repeated string not_required_repeated_field = 2
+      [(cbor.tags) = { name: "NotRequiredRepeatedField" }];
+}
+
+message TypeOverrideMessage {
+  bytes text_as_bytes = 1
+      [(cbor.tags) = { name: "TextAsBytes", type: TYPE_TEXT_STRING }];
+  string bytes_as_text = 2
+      [(cbor.tags) = { name: "BytesAsText", type: TYPE_BYTE_STRING }];
+  int32 uint_as_int = 3 [(cbor.tags) = { name: "UintAsInt", type: TYPE_UINT }];
+}
+
+message NestedMessage {
+  string value = 1 [(cbor.tags) = { name: "Value" }];
+}
+
+message ComplexMessage {
+  int32 int32_field = 1 [(cbor.tags) = { name: "Int32Field" }];
+  int64 int64_field = 2 [(cbor.tags) = { name: "Int64Field" }];
+  uint32 uint32_field = 3 [(cbor.tags) = { name: "Uint32Field" }];
+  uint64 uint64_field = 4 [(cbor.tags) = { name: "Uint64Field" }];
+  string string_field = 5 [(cbor.tags) = { name: "StringField" }];
+  bytes bytes_field = 6 [(cbor.tags) = { name: "BytesField" }];
+  bool bool_field = 7 [(cbor.tags) = { name: "BoolField" }];
+  float float_field = 8 [(cbor.tags) = { name: "FloatField" }];
+  double double_field = 9 [(cbor.tags) = { name: "DoubleField" }];
+
+  repeated int32 repeated_int32 = 10 [(cbor.tags) = { name: "RepeatedInt32" }];
+  repeated string repeated_string = 11
+      [(cbor.tags) = { name: "RepeatedString" }];
+
+  NestedMessage nested = 12 [(cbor.tags) = { name: "Nested" }];
+  repeated NestedMessage repeated_nested = 13
+      [(cbor.tags) = { name: "RepeatedNested" }];
+
+  repeated bytes repeated_bytes = 20 [(cbor.tags) = { name: "RepeatedBytes" }];
+  repeated string repeated_string_array = 21
+      [(cbor.tags) = { name: "RepeatedStringArray" }];
+  repeated int64 repeated_int64 = 22 [(cbor.tags) = { name: "RepeatedInt64" }];
+  repeated uint32 repeated_uint32 = 23
+      [(cbor.tags) = { name: "RepeatedUint32" }];
+  repeated uint64 repeated_uint64 = 24
+      [(cbor.tags) = { name: "RepeatedUint64" }];
+  repeated bool repeated_bool = 25 [(cbor.tags) = { name: "RepeatedBool" }];
+  repeated float repeated_float = 26 [(cbor.tags) = { name: "RepeatedFloat" }];
+  repeated double repeated_double = 27
+      [(cbor.tags) = { name: "RepeatedDouble" }];
+
+  string read_only_field = 14
+      [(cbor.tags) = { name: "ReadOnly", mode: MODE_READ_ONLY }];
+  string write_only_field = 15
+      [(cbor.tags) = { name: "WriteOnly", mode: MODE_WRITE_ONLY }];
+  string skip_field = 16 [(cbor.tags) = { mode: MODE_SKIP }];
+  ExternalMessage external_field = 17 [(cbor.tags) = { name: "ExternalField" }];
+  ExternalSkipMessage skip_external_field = 18
+      [(cbor.tags) = { mode: MODE_SKIP }];
+  google.protobuf.Timestamp timestamp_field = 19
+      [(cbor.tags) = { mode: MODE_SKIP }];
+}
diff --git a/claim/BUILD b/claim/BUILD
new file mode 100644
index 0000000..82d2821
--- /dev/null
+++ b/claim/BUILD
@@ -0,0 +1,125 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "manifest_store_view",
+    srcs = ["manifest_store_view.cc"],
+    hdrs = ["manifest_store_view.h"],
+    deps = [
+        "//constants:labels",
+        "//cose:sig_structure",
+        "//jumbf:box",
+        "//jumbf:parse",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "manifest_store_view_test",
+    srcs = ["manifest_store_view_test.cc"],
+    deps = [
+        ":manifest_store_view",
+        "//constants:labels",
+        "//cose:sig_structure",
+        "//jumbf:box",
+        "//jumbf:test_utils",
+        "//testing:jumbf_utils",
+        "//uuid",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "claim_cbor_encoder",
+    srcs = ["claim_cbor_encoder.cc"],
+    hdrs = ["claim_cbor_encoder.h"],
+    deps = [
+        "//cbor",
+        "//proto:manifest_cbor_proto",
+        "//proto:manifest_cc_proto",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
+
+cc_library(
+    name = "validator",
+    srcs = ["validator.cc"],
+    hdrs = ["validator.h"],
+    deps = [
+        "//cbor",
+        "//cbor:parse",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//cose:verifier",
+        "//jumbf:box",
+        "//proto:assertion_cc_proto",
+        "//proto:cose_verification_result_cc_proto",
+        "//proto:generator_info_cbor_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//uuid",
+        "//validator:tracker",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_test(
+    name = "validator_test",
+    srcs = ["validator_test.cc"],
+    deps = [
+        ":validator",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//jumbf:encode_params",
+        "//proto:cose_verification_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//testing:cbor_utils",
+        "//testing:jumbf_utils",
+        "//testing:mock_cose_verifier",
+        "//testing:proto_test_utils",
+        "//testing:test_validation_tracker",
+        "//uuid",
+        "//validator:tracker",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/claim/claim_cbor_encoder.cc b/claim/claim_cbor_encoder.cc
new file mode 100644
index 0000000..2e1379a
--- /dev/null
+++ b/claim/claim_cbor_encoder.cc
@@ -0,0 +1,61 @@
+// 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 "claim/claim_cbor_encoder.h"
+
+#include <string>
+
+#include "absl/base/no_destructor.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "cbor/options.h"
+#include "proto/manifest.cbor.h"
+#include "proto/manifest.pb.h"
+
+namespace credentio {
+namespace {
+
+class StandardClaimCborEncoder : public ClaimCborEncoder {
+ public:
+  explicit StandardClaimCborEncoder(bool skip_validity_checks_for_test = false)
+      : options_({.skip_validation = skip_validity_checks_for_test}) {}
+  ~StandardClaimCborEncoder() override = default;
+
+  absl::StatusOr<std::string> ToCbor(const Claim& claim) const override {
+    ABSL_ASSIGN_OR_RETURN(auto cbor, cbor::FromProto(claim, options_));
+    return cbor->toString();
+  }
+
+ private:
+  cbor::FromProtoOptions options_;
+};
+
+}  // namespace
+
+const ClaimCborEncoder& GetClaimCborEncoder(
+    bool skip_validity_checks_for_test) {
+  if (skip_validity_checks_for_test) {
+    static const absl::NoDestructor<StandardClaimCborEncoder> encoder_for_test(
+        /*skip_validity_checks_for_test=*/true);
+    return *encoder_for_test;
+  } else {
+    static const absl::NoDestructor<StandardClaimCborEncoder> encoder(
+        /*skip_validity_checks_for_test=*/false);
+    return *encoder;
+  }
+}
+
+}  // namespace credentio
diff --git a/claim/claim_cbor_encoder.h b/claim/claim_cbor_encoder.h
new file mode 100644
index 0000000..d54c3f9
--- /dev/null
+++ b/claim/claim_cbor_encoder.h
@@ -0,0 +1,39 @@
+// 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_CLAIM_CLAIM_CBOR_ENCODER_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_CLAIM_CBOR_ENCODER_H_
+
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "proto/manifest.pb.h"
+
+namespace credentio {
+
+// An encoder to serialize a C2PA Claim as CBOR.
+class ClaimCborEncoder {
+ public:
+  virtual ~ClaimCborEncoder() = default;
+
+  virtual absl::StatusOr<std::string> ToCbor(const Claim& claim) const = 0;
+};
+
+// Returns a standard singleton ClaimCborEncoder.
+const ClaimCborEncoder& GetClaimCborEncoder(bool skip_validity_checks_for_test);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_CLAIM_CBOR_ENCODER_H_
diff --git a/claim/manifest_store_view.cc b/claim/manifest_store_view.cc
new file mode 100644
index 0000000..97bde31
--- /dev/null
+++ b/claim/manifest_store_view.cc
@@ -0,0 +1,129 @@
+// 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 "claim/manifest_store_view.h"
+
+#include <optional>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "cose/sig_structure.h"
+#include "jumbf/box.h"
+#include "jumbf/parse.h"
+
+namespace credentio {
+namespace {
+bool IsManifestStore(const jumbf::SuperBox& superbox) {
+  return superbox.description.type_uuid == kManifestStoreUuid &&
+         superbox.description.label == kManifestStoreLabel;
+}
+
+bool IsManifest(const jumbf::SuperBox& superbox) {
+  return superbox.description.type_uuid == kStandardManifestUuid ||
+         superbox.description.type_uuid == kUpdateManifestUuid ||
+         superbox.description.type_uuid == kTimestampManifestUuid ||
+         superbox.description.type_uuid == kCompressedManifestUuid;
+}
+
+bool IsClaimSignature(const jumbf::SuperBox& superbox) {
+  return superbox.description.type_uuid == kClaimSignatureUuid &&
+         superbox.description.label == kClaimSignatureLabel;
+}
+}  // namespace
+
+absl::StatusOr<ManifestStoreView> ManifestStoreView::Create(
+    absl::string_view manifest_store) {
+  ABSL_ASSIGN_OR_RETURN(
+      auto super_box_res,
+      jumbf::ConsumeSuperBox(&manifest_store, /*recursion_limit=*/4));
+  jumbf::SuperBox super_box = std::move(super_box_res);
+  if (!IsManifestStore(super_box)) {
+    return absl::InvalidArgumentError("No C2PA manifest store");
+  }
+  return ManifestStoreView(std::move(super_box));
+}
+
+absl::StatusOr<ManifestView> ManifestStoreView::GetActiveManifest() const {
+  const jumbf::SuperBox* active_manifest = nullptr;
+  for (const auto& box : superbox_.contents) {
+    if (box.Holds<jumbf::SuperBox>()) {
+      const auto& superbox = box.Get<jumbf::SuperBox>();
+      if (IsManifest(superbox)) {
+        active_manifest = &superbox;
+      }
+    }
+  }
+  if (active_manifest == nullptr) {
+    return absl::InvalidArgumentError("No active manifest found");
+  }
+  return ManifestView::Create(*active_manifest);
+}
+
+absl::StatusOr<ManifestView> ManifestView::Create(jumbf::SuperBox superbox) {
+  if (!IsManifest(superbox)) {
+    return absl::InvalidArgumentError("Not a manifest box");
+  }
+  if (superbox.description.type_uuid == kCompressedManifestUuid) {
+    return absl::InvalidArgumentError("Compressed manifests are not supported");
+  }
+  return ManifestView(std::move(superbox));
+}
+
+absl::StatusOr<ClaimSignatureView> ManifestView::GetClaimSignature() const {
+  std::optional<jumbf::SuperBox> claim_signature_superbox = std::nullopt;
+  for (const auto& content : superbox_.contents) {
+    if (content.Holds<jumbf::SuperBox>() &&
+        IsClaimSignature(content.Get<jumbf::SuperBox>())) {
+      claim_signature_superbox = content.Get<jumbf::SuperBox>();
+      break;
+    }
+  }
+  if (!claim_signature_superbox.has_value()) {
+    return absl::InvalidArgumentError(
+        "No claim signature found in the manifest");
+  }
+  return ClaimSignatureView::Create(claim_signature_superbox.value());
+}
+
+absl::string_view ManifestView::label() const {
+  return superbox_.description.label.value_or("");
+}
+
+absl::StatusOr<ClaimSignatureView> ClaimSignatureView::Create(
+    jumbf::SuperBox superbox) {
+  if (!IsClaimSignature(superbox)) {
+    return absl::InvalidArgumentError("Not a claim signature box");
+  }
+  return ClaimSignatureView(std::move(superbox));
+}
+
+absl::StatusOr<absl::string_view> ClaimSignatureView::GetPayload() const {
+  if (superbox_.contents.size() != 1 ||
+      !superbox_.contents[0].Holds<jumbf::CborBox>()) {
+    return absl::InvalidArgumentError("Invalid claim signature box");
+  }
+  return superbox_.contents[0].Get<jumbf::CborBox>().payload;
+}
+
+absl::StatusOr<CoseSign1TaggedStructure> ClaimSignatureView::GetStruct() const {
+  ABSL_ASSIGN_OR_RETURN(auto payload_res, GetPayload());
+  return DecodeCoseSign1TaggedStructure(payload_res);
+}
+
+}  // namespace credentio
diff --git a/claim/manifest_store_view.h b/claim/manifest_store_view.h
new file mode 100644
index 0000000..15c8c98
--- /dev/null
+++ b/claim/manifest_store_view.h
@@ -0,0 +1,67 @@
+// 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_CLAIM_MANIFEST_STORE_VIEW_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_VIEW_H_
+
+#include <utility>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "cose/sig_structure.h"
+#include "jumbf/box.h"
+
+namespace credentio {
+
+class ClaimSignatureView {
+ public:
+  static absl::StatusOr<ClaimSignatureView> Create(jumbf::SuperBox superbox);
+  absl::StatusOr<absl::string_view> GetPayload() const;
+  absl::StatusOr<CoseSign1TaggedStructure> GetStruct() const;
+  jumbf::SuperBox superbox() const { return superbox_; }
+
+ private:
+  explicit ClaimSignatureView(jumbf::SuperBox superbox)
+      : superbox_(std::move(superbox)) {}
+  jumbf::SuperBox superbox_;
+};
+
+class ManifestView {
+ public:
+  static absl::StatusOr<ManifestView> Create(jumbf::SuperBox superbox);
+  absl::StatusOr<ClaimSignatureView> GetClaimSignature() const;
+  absl::string_view label() const;
+  jumbf::SuperBox superbox() const { return superbox_; }
+
+ private:
+  explicit ManifestView(jumbf::SuperBox superbox)
+      : superbox_(std::move(superbox)) {}
+  jumbf::SuperBox superbox_;
+};
+
+class ManifestStoreView {
+ public:
+  static absl::StatusOr<ManifestStoreView> Create(
+      absl::string_view manifest_store);
+  absl::StatusOr<ManifestView> GetActiveManifest() const;
+  jumbf::SuperBox superbox() const { return superbox_; }
+
+ private:
+  explicit ManifestStoreView(jumbf::SuperBox superbox) : superbox_(superbox) {}
+  jumbf::SuperBox superbox_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_VIEW_H_
diff --git a/claim/manifest_store_view_test.cc b/claim/manifest_store_view_test.cc
new file mode 100644
index 0000000..c8e0b3d
--- /dev/null
+++ b/claim/manifest_store_view_test.cc
@@ -0,0 +1,249 @@
+// 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 "claim/manifest_store_view.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "constants/labels.h"
+#include "cose/sig_structure.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/test_utils.h"
+#include "testing/jumbf_utils.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(ManifestStoreViewTest, WrongManifestStoreUuid) {
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kClaimUuid, kManifestStoreLabel, {jumbf::EncodeCborBox("")});
+  EXPECT_THAT(ManifestStoreView::Create(manifest_store),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No C2PA manifest store")));
+}
+
+TEST(ManifestStoreViewTest, WrongManifestStoreLabel) {
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, "wrong_label", {jumbf::EncodeCborBox("")});
+  EXPECT_THAT(ManifestStoreView::Create(manifest_store),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No C2PA manifest store")));
+}
+
+TEST(ManifestStoreViewTest, SingleStandardManifest) {
+  std::string manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest1", {jumbf::EncodeCborBox("")});
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  auto manifest_view = view->GetActiveManifest();
+  ASSERT_THAT(manifest_view, IsOk());
+  EXPECT_EQ(manifest_view->label(), "manifest1");
+}
+
+TEST(ManifestStoreViewTest, MultipleManifests) {
+  std::string manifest1 = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest1", {jumbf::EncodeCborBox("")});
+  std::string manifest2 = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest2", {jumbf::EncodeCborBox("")});
+  std::string manifest3 = jumbf::EncodeSuperBox(
+      kUpdateManifestUuid, "manifest3", {jumbf::EncodeCborBox("")});
+  std::string manifest_store =
+      jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
+                            {manifest1, manifest2, manifest3});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  auto manifest_view = view->GetActiveManifest();
+  ASSERT_THAT(manifest_view, IsOk());
+  EXPECT_EQ(manifest_view->label(), "manifest3");
+}
+
+TEST(ManifestStoreViewTest, NonManifestBoxesIgnored) {
+  std::string manifest1 = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest1", {jumbf::EncodeCborBox("")});
+  std::string manifest2 = jumbf::EncodeSuperBox(
+      kUpdateManifestUuid, "manifest2", {jumbf::EncodeCborBox("")});
+  std::string other_box1 =
+      jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box1",
+                            {jumbf::EncodeCborBox("")});
+  std::string other_box2 =
+      jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box2",
+                            {jumbf::EncodeCborBox("")});
+  std::string manifest_store =
+      jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
+                            {manifest1, other_box1, manifest2, other_box2});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  auto manifest_view = view->GetActiveManifest();
+  ASSERT_THAT(manifest_view, IsOk());
+  EXPECT_EQ(manifest_view->label(), "manifest2");
+}
+
+TEST(ManifestStoreViewTest, NoManifests) {
+  std::string other_box1 =
+      jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box1",
+                            {jumbf::EncodeCborBox("")});
+  std::string other_box2 =
+      jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box2",
+                            {jumbf::EncodeCborBox("")});
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {other_box1, other_box2});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  EXPECT_THAT(view->GetActiveManifest(),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No active manifest found")));
+}
+
+TEST(ManifestStoreViewTest, CompressedManifestNotSupported) {
+  std::string standard_manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "label1", {jumbf::EncodeCborBox("")});
+  std::string compressed_manifest = jumbf::EncodeSuperBox(
+      kCompressedManifestUuid, "label2", {jumbf::EncodeCborBox("")});
+  std::string manifest_store =
+      jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
+                            {standard_manifest, compressed_manifest});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  EXPECT_THAT(view->GetActiveManifest(),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Compressed manifests are not supported")));
+}
+
+TEST(ManifestViewTest, NotManifest) {
+  std::string encoded_box =
+      jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box1",
+                            {jumbf::EncodeCborBox("")});
+  jumbf::SuperBox box = ParseSuperBoxOrDie(&encoded_box);
+  EXPECT_THAT(ManifestView::Create(box),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Not a manifest box")));
+}
+
+TEST(ManifestViewTest, WrongClaimSignatureUuid) {
+  std::string claim_signature = jumbf::EncodeSuperBox(
+      kClaimUuid, kClaimSignatureLabel, {jumbf::EncodeCborBox("")});
+  std::string manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest_label", {claim_signature});
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  auto manifest_view = view->GetActiveManifest();
+  ASSERT_THAT(manifest_view, IsOk());
+  auto result = manifest_view->GetClaimSignature();
+  EXPECT_THAT(result.status(),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No claim signature found in the manifest")));
+}
+
+TEST(ManifestViewTest, WrongClaimSignatureLabel) {
+  std::string claim_signature = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, "wrong_label", {jumbf::EncodeCborBox("")});
+  std::string manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest_label", {claim_signature});
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  auto manifest_view = view->GetActiveManifest();
+  ASSERT_THAT(manifest_view, IsOk());
+  auto result = manifest_view->GetClaimSignature();
+  EXPECT_THAT(result.status(),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No claim signature found in the manifest")));
+}
+
+TEST(ClaimSignatureViewTest, NotClaimSignature) {
+  std::string encoded_box =
+      jumbf::EncodeSuperBox(UuidGenerator::Default().Generate(), "other_box1",
+                            {jumbf::EncodeCborBox("")});
+  jumbf::SuperBox box = ParseSuperBoxOrDie(&encoded_box);
+  EXPECT_THAT(ClaimSignatureView::Create(box),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Not a claim signature box")));
+}
+
+TEST(ClaimSignatureViewTest, InvalidMultipleClaimSignature) {
+  std::string claim_signature = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, kClaimSignatureLabel,
+      {jumbf::EncodeCborBox("abc"), jumbf::EncodeCborBox("def")});
+  std::string manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest_label", {claim_signature});
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  auto manifest_view = view->GetActiveManifest();
+  ASSERT_THAT(manifest_view, IsOk());
+  auto claim_signature_view = manifest_view->GetClaimSignature();
+  ASSERT_THAT(claim_signature_view, IsOk());
+  EXPECT_THAT(claim_signature_view->GetPayload(),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Invalid claim signature box")));
+}
+
+TEST(ClaimSignatureViewTest, GetPayloadSuccess) {
+  std::string claim_signature = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, kClaimSignatureLabel, {jumbf::EncodeCborBox("abc")});
+  std::string manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest_label", {claim_signature});
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  auto manifest_view = view->GetActiveManifest();
+  ASSERT_THAT(manifest_view, IsOk());
+  auto claim_signature_view = manifest_view->GetClaimSignature();
+  ASSERT_THAT(claim_signature_view, IsOk());
+  EXPECT_THAT(claim_signature_view->GetPayload(), IsOkAndHolds("abc"));
+}
+
+TEST(ClaimSignatureViewTest, GetStructSuccess) {
+  CoseSign1TaggedStructure cose_sign1{
+      .unprotected_header = {.sig_tst2 =
+                                 TstContainer{
+                                     .tst_tokens = {{.val = "timestamp"}}}},
+      .signature = "abc",
+  };
+  std::string claim_signature = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, kClaimSignatureLabel,
+      {jumbf::EncodeCborBox(EncodeCoseSign1TaggedStructure(cose_sign1))});
+  std::string manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "manifest_label", {claim_signature});
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest});
+  auto view = ManifestStoreView::Create(manifest_store);
+  ASSERT_THAT(view, IsOk());
+  auto manifest_view = view->GetActiveManifest();
+  ASSERT_THAT(manifest_view, IsOk());
+  auto claim_signature_view = manifest_view->GetClaimSignature();
+  ASSERT_THAT(claim_signature_view, IsOk());
+  EXPECT_THAT(claim_signature_view->GetStruct(), IsOkAndHolds(cose_sign1));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/claim/validator.cc b/claim/validator.cc
new file mode 100644
index 0000000..777cd27
--- /dev/null
+++ b/claim/validator.cc
@@ -0,0 +1,439 @@
+// 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 "claim/validator.h"
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "cbor/cbor.h"
+#include "cbor/parse.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "jumbf/box.h"
+#include "proto/assertion.pb.h"
+#include "proto/cose_verification_result.pb.h"
+#include "proto/generator_info.cbor.h"
+#include "proto/generator_info.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/signature_info.pb.h"
+#include "uuid/uuid.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio::Uuid;
+using ::jumbf::CborBox;
+using ::jumbf::SuperBox;
+
+constexpr absl::string_view kSignatureKey = "signature";
+constexpr absl::string_view kInstanceIdKey = "instanceID";
+constexpr absl::string_view kClaimSignatureUri = "self#jumbf=c2pa.signature";
+constexpr absl::string_view kClaimSignatureUriPrefix = "self#jumbf=";
+constexpr absl::string_view kClaimSignatureUriSuffix = "c2pa.signature";
+constexpr absl::string_view kClaimGeneratorInfoKey = "claim_generator_info";
+constexpr absl::string_view kClaimGeneratorV1Key = "claim_generator";
+constexpr absl::string_view kCreatedAssertionsKey = "created_assertions";
+constexpr absl::string_view kGatheredAssertionsKey = "gathered_assertions";
+constexpr absl::string_view kRedactedAssertionsKey = "redacted_assertions";
+constexpr absl::string_view kUrlKey = "url";
+constexpr absl::string_view kHashKey = "hash";
+constexpr absl::string_view kAlgKey = "alg";
+
+absl::StatusOr<absl::string_view> ExtractCbor(const SuperBox* box) {
+  // Check if the box only contains a single CBOR content type.
+  if (box->contents.size() != 1) {
+    return absl::InvalidArgumentError(
+        "box contains zero or more than one contents");
+  }
+  const auto& content = box->contents[0];
+  if (!content.Holds<CborBox>()) {
+    return absl::InvalidArgumentError("box does not contain a CBOR content");
+  }
+  return content.Get<CborBox>().payload;
+}
+
+// See hashed-uri-map in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_uri_references.
+struct HashedUriInternal {
+  absl::string_view url;
+  absl::string_view hash;
+  std::optional<absl::string_view> algorithm;
+
+  HashedUri ToProto(std::optional<absl::string_view> default_algorithm) const {
+    HashedUri hashed_uri;
+    hashed_uri.set_url(url);
+    hashed_uri.set_hash(hash);
+    hashed_uri.set_algorithm(
+        algorithm.value_or(default_algorithm.value_or("")));
+    return hashed_uri;
+  }
+};
+
+std::vector<HashedUriInternal> ParseHashedUriArray(
+    const cbor::ArrayView& uri_array, ValidationTracker& validation_tracker) {
+  std::vector<HashedUriInternal> assertions;
+  for (int i = 0; i < uri_array.size(); ++i) {
+    auto uri_map = uri_array.GetMap(i);
+    if (!uri_map.ok()) {
+      validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing,
+                                       {});
+      continue;
+    }
+    auto url = uri_map->GetString(kUrlKey);
+    if (!url.ok()) {
+      validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing,
+                                       {});
+      continue;
+    }
+    auto hash = uri_map->GetByteString(kHashKey);
+    if (!hash.ok()) {
+      validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing,
+                                       {.url = *url});
+      continue;
+    }
+    auto algorithm = uri_map->GetOptionalString(kAlgKey);
+    assertions.push_back(
+        HashedUriInternal{.url = *url, .hash = *hash, .algorithm = algorithm});
+  }
+  return assertions;
+}
+
+GeneratorInfo CreateGeneratorInfo(const cbor::MapView& cbor_map,
+                                  absl::string_view claim_box_label,
+                                  ValidationTracker& validation_tracker) {
+  GeneratorInfo generator_info;
+  auto holder = cbor_map.GetOptionalMap(kClaimGeneratorInfoKey);
+  if (holder.has_value()) {
+    auto info_map = *holder;
+    auto status = cbor::ToProto(info_map, &generator_info);
+    if (!status.ok()) {
+      validation_tracker.RecordFailure(FailureStatusCode::kClaimCborInvalid,
+                                       {.url = claim_box_label});
+    }
+  } else if (auto v1_generator =
+                 cbor_map.GetOptionalString(kClaimGeneratorV1Key);
+             v1_generator.has_value()) {
+    // The v1 `claim_generator` is formatted as a User-Agent string, but
+    // that's at least arguably a "human readable string naming the claim
+    // generator" as the `name` field is described.
+    generator_info.set_name(*v1_generator);
+  } else {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kClaimMalformed,
+        {.url = claim_box_label,
+         .explanation = "Claim is missing generator info"});
+  }
+  return generator_info;
+}
+
+struct CreateClaimOptions {
+  const std::string label;
+  const cbor::MapView& claim_map;
+  const absl::Span<HashedUri> created_assertions;
+  const absl::Span<HashedUri> gathered_assertions;
+  const absl::Span<std::string> redacted_assertions;
+  const std::optional<SignatureInfo> signature_info;
+  std::optional<std::string> default_algorithm;
+};
+
+Claim CreateClaim(const CreateClaimOptions& options,
+                  ValidationTracker& validation_tracker) {
+  // Construct the claim proto.
+  Claim claim;
+  claim.set_label(options.label);
+  // Add instance ID.
+  std::optional<absl::string_view> instance_id =
+      options.claim_map.GetOptionalString(kInstanceIdKey);
+  if (instance_id.has_value()) {
+    claim.set_instance_id(*instance_id);
+  } else {
+    validation_tracker.RecordFailure(
+        FailureStatusCode::kClaimMalformed,
+        {.url = options.label, .explanation = "Claim is missing instance ID"});
+  }
+  // Add signature info.
+  if (options.signature_info.has_value()) {
+    *claim.mutable_signature_info() = *options.signature_info;
+  }
+  // Add generator info.
+  *claim.mutable_claim_generator_info() =
+      CreateGeneratorInfo(options.claim_map, options.label, validation_tracker);
+  // Add assertion URIs
+  claim.mutable_created_assertions()->Add(options.created_assertions.begin(),
+                                          options.created_assertions.end());
+  claim.mutable_gathered_assertions()->Add(options.gathered_assertions.begin(),
+                                           options.gathered_assertions.end());
+  claim.mutable_redacted_assertions()->Add(options.redacted_assertions.begin(),
+                                           options.redacted_assertions.end());
+  if (options.default_algorithm.has_value()) {
+    claim.set_default_algorithm(*options.default_algorithm);
+  }
+  return claim;
+}
+
+bool IsClaimBox(absl::string_view label, const Uuid& uuid) {
+  if (label == kClaimV2Label && uuid == kClaimUuid) {
+    return true;
+  }
+  if (label == kClaimV1Label && uuid == kClaimUuid) {
+    return true;
+  }
+  return false;
+}
+
+bool IsLegacyClaimBox(absl::string_view label, const Uuid& uuid) {
+  return label == kClaimV1Label && uuid == kClaimUuid;
+}
+
+// Checks if the signature URI is a self#jumbf location in the current manifest
+// that is being processed (provided in the `manifest` parameter).
+bool IsValidSignatureUri(absl::string_view uri,
+                         const jumbf::SuperBox& manifest) {
+  // Check if the signature URI is a relative URI first for efficiency.
+  if (uri == kClaimSignatureUri) {
+    return true;
+  }
+  return manifest.description.label.has_value() &&
+         uri == absl::StrCat(kClaimSignatureUriPrefix, "/c2pa/",
+                             *manifest.description.label, "/",
+                             kClaimSignatureUriSuffix);
+}
+
+}  // namespace
+
+std::optional<Claim> ClaimValidatorImpl::Validate(
+    const jumbf::SuperBox& manifest,
+    ValidationTracker& validation_tracker) const {
+  std::optional<const jumbf::SuperBox*> claim_box;
+  std::string claim_box_label = "";
+  std::optional<const jumbf::SuperBox*> claim_signature_box;
+
+  // Parse manifest boxes to find the claim and claim signature boxes.
+  for (const auto& box : manifest.contents) {
+    if (box.Holds<SuperBox>()) {
+      const auto& description = box.Get<SuperBox>().description;
+      absl::string_view label = description.label.value_or("");
+      Uuid uid = description.type_uuid;
+      if (IsLegacyClaimBox(label, uid)) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kGoogleUnsupportedSpecVersion,
+            {.url = label, .explanation = "Unsupported claim version"});
+        return std::nullopt;
+      }
+      if (IsClaimBox(label, uid)) {
+        if (claim_box.has_value()) {
+          validation_tracker.RecordFailure(FailureStatusCode::kClaimMultiple,
+                                           {.url = label});
+        }
+        if (box.Holds<SuperBox>()) {
+          claim_box = &box.Get<SuperBox>();
+          claim_box_label = label;
+        }
+      } else if (label == kClaimSignatureLabel && uid == kClaimSignatureUuid) {
+        if (box.Holds<SuperBox>()) {
+          claim_signature_box = &box.Get<SuperBox>();
+        }
+      }
+    }
+  }
+
+  // Claim box should be present and it shall consist of a single CBOR content
+  // type. Extract the CBOR content type from the claim box if these
+  // conditions are met, otherwise update the failure status.
+
+  absl::string_view claim_cbor = "";
+  if (!claim_box.has_value()) {
+    validation_tracker.RecordFailure(FailureStatusCode::kClaimMissing,
+                                     {.url = claim_box_label});
+  } else {
+    auto result = ExtractCbor(*claim_box);
+    if (!result.ok()) {
+      validation_tracker.RecordFailure(FailureStatusCode::kClaimCborInvalid,
+                                       {.url = claim_box_label});
+    } else {
+      claim_cbor = *result;
+    }
+  }
+
+  // Parse the claim CBOR as a map and validate the signature field.
+  // The signature URI must refer to a location within the same C2PA Manifest
+  // box (a self#jumbf location). Moreover, since the claim signature label
+  // must be `c2pa.signature`, this implies that the signature URI should be
+  // equal to `self#jumbf:c2pa.signature`.
+
+  AssertionUris assertion_uris;
+  std::optional<cbor::MapView> claim_map;
+  auto claim_result = cbor::Parse(claim_cbor);
+  if (!claim_result.ok()) {
+    validation_tracker.RecordFailure(FailureStatusCode::kClaimCborInvalid,
+                                     {.url = claim_box_label});
+  } else {
+    auto claim_map_result = (*claim_result)->AsMap();
+    if (!claim_map_result.ok()) {
+      validation_tracker.RecordFailure(FailureStatusCode::kClaimCborInvalid,
+                                       {.url = claim_box_label});
+    } else {
+      // Validate the signature field.
+      claim_map = *claim_map_result;
+      auto signature_uri = claim_map->GetString(kSignatureKey);
+      if (!signature_uri.ok()) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kClaimMalformed,
+            {.url = claim_box_label,
+             .explanation = "Claim is missing signature URI"});
+      } else if (!IsValidSignatureUri(*signature_uri, manifest)) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kClaimSignatureMissing,
+            {.url = kClaimSignatureLabel});
+      }
+      // Extract the optional `alg` field.
+      assertion_uris.default_algorithm = claim_map->GetOptionalString(kAlgKey);
+      // Extract the required `created_assertions` field.
+      assertion_uris.created_assertions =
+          claim_map->GetOptionalArray(kCreatedAssertionsKey);
+      // Extract the optional `gathered_assertions` field.
+      assertion_uris.gathered_assertions =
+          claim_map->GetOptionalArray(kGatheredAssertionsKey);
+      // Extract the optional `redacted_assertions` field
+      assertion_uris.redacted_assertions =
+          claim_map->GetOptionalArray(kRedactedAssertionsKey);
+      // Confirm that the `created_assertions` field is set. This field is
+      // required in c2pa.claim.v2.
+      if (!assertion_uris.created_assertions.has_value()) {
+        validation_tracker.RecordFailure(
+            FailureStatusCode::kClaimMalformed,
+            {.url = claim_box_label,
+             .explanation = "Claim is missing created assertions"});
+      }
+    }
+  }
+
+  // Claim signature box should be present and it shall consist of a single
+  // CBOR content type. Extract the CBOR content type from the claim signature
+  // box if these conditions are met, otherwise update the failure status.
+
+  std::optional<absl::string_view> claim_signature_cbor;
+  if (!claim_signature_box.has_value()) {
+    validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing,
+                                     {.url = kClaimSignatureLabel});
+  } else {
+    auto result = ExtractCbor(*claim_signature_box);
+    if (!result.ok()) {
+      validation_tracker.RecordFailure(
+          FailureStatusCode::kClaimSignatureMissing,
+          {.url = kClaimSignatureLabel});
+    } else {
+      claim_signature_cbor = *result;
+    }
+  }
+
+  std::optional<SignatureInfo> signature_info;
+  if (claim_signature_cbor.has_value() && !claim_cbor.empty()) {
+    CoseVerificationResult verification_result =
+        cose_verifier_.Verify(*claim_signature_cbor, claim_cbor);
+    if (verification_result.has_signature_info()) {
+      signature_info = verification_result.signature_info();
+    }
+    validation_tracker.MergeStatuses(verification_result.validation_status());
+    if (!verification_result.has_signature_info() &&
+        !validation_tracker.HasFailures()) {
+      LOG_EVERY_N_SEC(ERROR, 60) << "Signature verification "
+                                    "failed but no failure was recorded";
+      validation_tracker.RecordFailure(FailureStatusCode::kGoogleInternalError,
+                                       {.url = kClaimSignatureLabel});
+    }
+  } else {
+    validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing,
+                                     {.url = kClaimSignatureLabel});
+  }
+
+  ClaimValidatorImpl::ValidateAssertionResult validate_assertion_result;
+  if (claim_box.has_value()) {
+    validate_assertion_result =
+        ValidateAssertions(assertion_uris, validation_tracker);
+  }
+
+  if (validation_tracker.HasFailures()) {
+    return std::nullopt;
+  }
+
+  auto options = CreateClaimOptions{
+      .label =
+          claim_box.has_value()
+              ? std::string(claim_box.value()->description.label.value_or(""))
+              : "",
+      .claim_map = *claim_map,
+      .created_assertions =
+          absl::MakeSpan(validate_assertion_result.created_assertions),
+      .gathered_assertions =
+          absl::MakeSpan(validate_assertion_result.gathered_assertions),
+      .redacted_assertions =
+          absl::MakeSpan(validate_assertion_result.redacted_assertions),
+      .signature_info = signature_info,
+      .default_algorithm = assertion_uris.default_algorithm,
+  };
+  return CreateClaim(options, validation_tracker);
+}
+
+ClaimValidatorImpl::ValidateAssertionResult
+ClaimValidatorImpl::ValidateAssertions(
+    const AssertionUris& assertion_uris,
+    ValidationTracker& validation_tracker) const {
+  // Parse CBOR maps in hashed URI arrays
+  std::vector<HashedUriInternal> created_assertions, gathered_assertions;
+  ClaimValidatorImpl::ValidateAssertionResult result;
+  if (assertion_uris.created_assertions.has_value()) {
+    created_assertions = ParseHashedUriArray(*assertion_uris.created_assertions,
+                                             validation_tracker);
+  }
+  if (assertion_uris.gathered_assertions.has_value()) {
+    gathered_assertions = ParseHashedUriArray(
+        *assertion_uris.gathered_assertions, validation_tracker);
+  }
+  for (const auto& a : created_assertions) {
+    result.created_assertions.push_back(
+        a.ToProto(assertion_uris.default_algorithm));
+  }
+  for (const auto& a : gathered_assertions) {
+    result.gathered_assertions.push_back(
+        a.ToProto(assertion_uris.default_algorithm));
+  }
+  std::vector<std::string> redacted_assertions;
+  if (assertion_uris.redacted_assertions.has_value()) {
+    for (int i = 0; i < assertion_uris.redacted_assertions->size(); ++i) {
+      auto uri = assertion_uris.redacted_assertions->GetString(i);
+      if (!uri.ok()) {
+        validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing,
+                                         {});
+      } else {
+        redacted_assertions.push_back(std::string(*uri));
+      }
+    }
+  }
+  result.redacted_assertions = redacted_assertions;
+  return result;
+}
+
+}  // namespace credentio
diff --git a/claim/validator.h b/claim/validator.h
new file mode 100644
index 0000000..fd5d45f
--- /dev/null
+++ b/claim/validator.h
@@ -0,0 +1,83 @@
+// 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_CLAIM_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_VALIDATOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/log/die_if_null.h"
+#include "cbor/cbor.h"
+#include "cose/verifier.h"
+#include "jumbf/box.h"
+#include "proto/assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+class ClaimValidator {
+ public:
+  virtual ~ClaimValidator() = default;
+  // Parses and validates a `Claim` JUMBF box of the provided `Manifest` JUMBF,
+  // recording status codes in the provided tracker. If the validation is
+  // successful, returns a `Claim` proto containing the validated claim.
+  virtual std::optional<Claim> Validate(
+      const jumbf::SuperBox& manifest,
+      ValidationTracker& validation_tracker) const = 0;
+};
+
+class ClaimValidatorImpl : public ClaimValidator {
+ public:
+  explicit ClaimValidatorImpl(const CoseVerifier* cose_verifier)
+      : cose_verifier_(*ABSL_DIE_IF_NULL(cose_verifier)) {}
+  ~ClaimValidatorImpl() override = default;
+  // Parses and validates a `Claim` JUMBF box of the provided `Manifest` JUMBF,
+  // recording status codes in the provided tracker. If the validation is
+  // successful, returns a `Claim` proto containing the validated claim.
+  std::optional<Claim> Validate(
+      const jumbf::SuperBox& manifest,
+      ValidationTracker& validation_tracker) const override;
+
+ private:
+  // Holder for created, gathered and redacted assertion URIs inside the claim.
+  struct AssertionUris {
+    std::optional<std::string> default_algorithm;
+    std::optional<cbor::ArrayView> created_assertions;
+    std::optional<cbor::ArrayView> gathered_assertions;
+    std::optional<cbor::ArrayView> redacted_assertions;
+  };
+
+  struct ValidateAssertionResult {
+    std::vector<credentio::HashedUri> created_assertions;
+    std::vector<credentio::HashedUri> gathered_assertions;
+    std::vector<std::string> redacted_assertions;
+  };
+
+  // Extracts hashed assertion URIs from the Claim. Records the C2PA failure
+  // status codes in the provided tracker.
+  //
+  // Returns a set of URI lists for created, gathered and redacted assertions.
+  ValidateAssertionResult ValidateAssertions(
+      const AssertionUris& assertion_uris,
+      ValidationTracker& validation_tracker) const;
+  const CoseVerifier& cose_verifier_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_VALIDATOR_H_
diff --git a/claim/validator_test.cc b/claim/validator_test.cc
new file mode 100644
index 0000000..cc948c1
--- /dev/null
+++ b/claim/validator_test.cc
@@ -0,0 +1,1106 @@
+// 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 "claim/validator.h"
+
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/log/check.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.h"
+#include "jumbf/encode_params.h"
+#include "proto/cose_verification_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/signature_info.pb.h"
+#include "testing/cbor_utils.h"
+#include "testing/jumbf_utils.h"
+#include "testing/mock_cose_verifier.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+#include "uuid/uuid.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::cbor::FromJson;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::jumbf::SuperBoxBuilder;
+using ::testing::Eq;
+using ::testing::IsEmpty;
+using ::testing::Optional;
+using ::testing::Return;
+using ::testing::SizeIs;
+
+struct ClaimBoxOptions {
+  std::string signature;
+};
+
+jumbf::BuiltSuperBox CreateClaimBox(const ClaimBoxOptions& options) {
+  auto cbor = FromJson(absl::Substitute(
+      R"json({"signature": "$0", "created_assertions": []})json",
+      options.signature));
+  SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{
+                                          .label = absl::Cord(kClaimV2Label)});
+  CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor)));
+  auto built_box = std::move(builder).Finalize();
+  CHECK_OK(built_box);
+  return *std::move(built_box);
+}
+
+jumbf::BuiltSuperBox CreateClaimBoxFromJson(absl::string_view json) {
+  auto cbor = FromJson(json);
+  SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{
+                                          .label = absl::Cord(kClaimV2Label)});
+  CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor)));
+  absl::StatusOr<jumbf::BuiltSuperBox> built_box =
+      std::move(builder).Finalize();
+  CHECK_OK(built_box);
+  return *std::move(built_box);
+}
+
+jumbf::BuiltSuperBox CreateOldClaimBoxFromJson(absl::string_view json) {
+  auto cbor = FromJson(json);
+  SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{
+                                          .label = absl::Cord(kClaimV1Label)});
+  CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor)));
+  absl::StatusOr<jumbf::BuiltSuperBox> box = std::move(builder).Finalize();
+  CHECK_OK(box);
+  return *std::move(box);
+}
+
+jumbf::BuiltSuperBox CreateClaimBoxFromCbor(absl::string_view cbor) {
+  SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{
+                                          .label = absl::Cord(kClaimV2Label)});
+  CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor)));
+  auto built_box = std::move(builder).Finalize();
+  CHECK_OK(built_box);
+  return *std::move(built_box);
+}
+
+jumbf::BuiltSuperBox CreateClaimBoxWithMultipleContents(
+    absl::string_view cbor, absl::string_view other_cbor) {
+  SuperBoxBuilder builder(kClaimUuid, jumbf::DescriptionBoxOptions{
+                                          .label = absl::Cord(kClaimV2Label)});
+  CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(cbor)));
+  CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord(other_cbor)));
+  auto built_box = std::move(builder).Finalize();
+  CHECK_OK(built_box);
+  return *std::move(built_box);
+}
+
+jumbf::BuiltSuperBox CreateClaimSignatureBox(
+    absl::string_view cose_certificate) {
+  SuperBoxBuilder builder(
+      kClaimSignatureUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimSignatureLabel)});
+  CHECK_OK(
+      builder.AddContent(jumbf::kCborBoxType, absl::Cord(cose_certificate)));
+  absl::StatusOr<jumbf::BuiltSuperBox> built_box =
+      std::move(builder).Finalize();
+  CHECK_OK(built_box);
+  return *std::move(built_box);
+}
+
+jumbf::BuiltSuperBox CreateClaimSignatureBoxWithMultipleContents(
+    absl::string_view cose_certificate,
+    absl::string_view other_cose_certificate) {
+  SuperBoxBuilder builder(
+      kClaimSignatureUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimSignatureLabel)});
+  CHECK_OK(
+      builder.AddContent(jumbf::kCborBoxType, absl::Cord(cose_certificate)));
+  CHECK_OK(builder.AddContent(jumbf::kCborBoxType,
+                              absl::Cord(other_cose_certificate)));
+  auto built_box = std::move(builder).Finalize();
+  CHECK_OK(built_box);
+  return *std::move(built_box);
+}
+
+jumbf::BuiltSuperBox CreateAssertionStoreBox() {
+  SuperBoxBuilder builder(
+      kAssertionStoreUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord(kAssertionStoreLabel)});
+  CHECK_OK(builder.AddContent(jumbf::kCborBoxType, absl::Cord("foo")));
+  auto built_box = std::move(builder).Finalize();
+  CHECK_OK(built_box);
+  return *std::move(built_box);
+}
+
+SuperBoxBuilder CreateManifestBuilder() {
+  return SuperBoxBuilder(
+      kStandardManifestUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord("c2pa.manifest")});
+}
+
+std::optional<Claim> ValidateWithBuilder(const ClaimValidatorImpl& validator,
+                                         SuperBoxBuilder builder,
+                                         ValidationTracker& tracker) {
+  auto built_super_box = std::move(builder).Finalize();
+  CHECK_OK(built_super_box);
+  std::string manifest_bytes =
+      std::string(std::move(*built_super_box).AsCord());
+  return validator.Validate(ParseSuperBoxOrDie(&manifest_bytes), tracker);
+}
+
+TEST(ValidateClaimTest, ClaimMissing) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimSignatureMissing) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimSignatureMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, BothClaimAndSignatureMissing) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  auto failures = t.GetFailures();
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimMissing));
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimSignatureMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, MultipleClaims) {
+  auto manifest = CreateManifestBuilder();
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some other cbor")),
+              IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk());
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimMultiple));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimHasMultipleContents) {
+  auto manifest = CreateManifestBuilder();
+  auto claim_box =
+      CreateClaimBoxWithMultipleContents("some cbor", "some other cbor");
+  ASSERT_THAT(manifest.AddChild(std::move(claim_box)), IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimCborInvalid));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimIsNotCborBox) {
+  auto manifest = CreateManifestBuilder();
+  SuperBoxBuilder claim_box(
+      kClaimUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimV2Label)});
+  SuperBoxBuilder child_box(kClaimSignatureUuid, {});
+  ASSERT_THAT(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo")),
+              IsOk());
+  ASSERT_THAT(claim_box.AddChild(std::move(child_box)), IsOk());
+  ASSERT_THAT(manifest.AddChild(std::move(claim_box)), IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimCborInvalid));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimSignatureHasMultipleContents) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk());
+  auto claim_signature_box = CreateClaimSignatureBoxWithMultipleContents(
+      "some cbor", "some other cbor");
+  ASSERT_THAT(manifest.AddChild(std::move(claim_signature_box)), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimSignatureMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimSignatureIsNotCborBox) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some_cbor")), IsOk());
+  SuperBoxBuilder claim_signature_box(
+      kClaimSignatureUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord(kClaimSignatureLabel)});
+  SuperBoxBuilder child_box(kClaimUuid, {});
+  ASSERT_THAT(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo")),
+              IsOk());
+  ASSERT_THAT(claim_signature_box.AddChild(std::move(child_box)), IsOk());
+  ASSERT_THAT(manifest.AddChild(std::move(claim_signature_box)), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimSignatureMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimCborIsNotAMap) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(R"json([1, 2, 3])json")),
+              IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimCborInvalid));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimHasIncorrectSignature) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(CreateClaimBox({.signature = "not_valid"})),
+              IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimSignatureMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, CoseVerificationFails) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(
+                  CreateClaimBox({.signature = "self#jumbf=c2pa.signature"})),
+              IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce([](absl::string_view cose_certificate,
+                   absl::string_view claim_cbor) {
+        CoseVerificationResult result;
+        result.mutable_validation_status()->mutable_failures()->Add()->set_code(
+            absl::StrCat(FailureStatusCode::kGeneralError));
+        return result;
+      });
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(result, Eq(std::nullopt));
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kGeneralError));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+}
+
+TEST(ValidateClaimTest, CoseVerifierFailsWithoutRecordingStatus) {
+  auto manifest = CreateManifestBuilder();
+  ASSERT_THAT(manifest.AddChild(
+                  CreateClaimBox({.signature = "self#jumbf=c2pa.signature"})),
+              IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(result, Eq(std::nullopt));
+  // Internal error recorded because the mock verifier didn't record one.
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kGoogleInternalError));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+}
+
+TEST(ValidateClaimTest, CreatedAssertionIsNotMap) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "signature": "self#jumbf=c2pa.signature",
+    "created_assertions": [{"url":"self#jumbf=c2pa.some_assertion"}],
+    "redacted_assertions": ["self#jumbf=c2pa.some_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, AssertionUriIsNotString) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "signature": "self#jumbf=c2pa.signature",
+    "created_assertions": [{
+      "url": 12,
+      "hash": "b64'abcd'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, HashedUriMissingRequiredFieldInCreated) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "signature": "self#jumbf=c2pa.signature",
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_assertion"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, HashedUriMissingRequiredFieldInGathered) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "signature": "self#jumbf=c2pa.signature",
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_assertion",
+      "hash": "b64'abcd'"
+    }],
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_assertion"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, HashedUriMissingRequiredFieldInRedacted) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "signature": "self#jumbf=c2pa.signature",
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_assertion",
+      "hash": "b64'abcd'"
+    }],
+    "redacted_assertions": [12]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kHashedUriMissing));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimV1ProvidedWithClaimV2Label) {
+  auto manifest = CreateManifestBuilder();
+  // Note use of v1 `assertions`.
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "dc:format": "image/jpeg",
+    "assertions": [
+      {"url": "self#jumbf=c2pa.some_created_assertion", "hash": "b64'aGVsbG8'"},
+      {"url": "self#jumbf=c2pa.some_gathered_assertion", "hash": "b64'aGVsbG8'"}
+    ]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)),
+              IsOk());  // Uses c2pa.claim.v2 label.
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+
+  SignatureInfo signature_info;
+  signature_info.set_issuer("fake_issuer");
+  signature_info.set_cert_serial_number("1234");
+  CoseVerificationResult verification_result;
+  *verification_result.mutable_signature_info() = signature_info;
+  EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailureStatuses(),
+              Contains(C2paStatusIs(FailureStatusCode::kClaimMalformed,
+                                    "Claim is missing created assertions")));
+}
+
+TEST(ValidateClaimTest, ClaimV1Unsupported) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "dc:format": "image/jpeg",
+    "assertions": [
+      {"url": "self#jumbf=c2pa.some_created_assertion", "hash": "b64'aGVsbG8'"},
+      {"url": "self#jumbf=c2pa.some_gathered_assertion", "hash": "b64'aGVsbG8'"}
+    ],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(
+      t.GetFailures(),
+      ContainsFailure(FailureStatusCode::kGoogleUnsupportedSpecVersion));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimV2HasNoSignature) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "dc:format": "image/jpeg",
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_created_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  EXPECT_CALL(mock_cose_verifier, Verify)
+      .WillOnce(Return(CoseVerificationResult()));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimMalformed));
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(result, Eq(std::nullopt));
+}
+
+TEST(ValidateClaimTest, ClaimV2HasNoInstanceId) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "dc:format": "image/jpeg",
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_created_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  SignatureInfo signature_info;
+  signature_info.set_issuer("fake_issuer");
+  signature_info.set_cert_serial_number("1234");
+  CoseVerificationResult verification_result;
+  *verification_result.mutable_signature_info() = signature_info;
+  EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimMalformed));
+  EXPECT_THAT(
+      result, Optional(EqualsProto(ParseTextProtoOrDie<Claim>(R"pb(
+        claim_generator_info {
+          name: "test_generator"
+          version: "1.0"
+          icon { url: "some_icon_link" hash: "hello" }
+          operating_system: "linux"
+        }
+        signature_info { cert_serial_number: "1234" issuer: "fake_issuer" }
+        created_assertions {
+          url: "self#jumbf=c2pa.some_created_assertion"
+          algorithm: ""
+          hash: "hello"
+        }
+        gathered_assertions {
+          url: "self#jumbf=c2pa.some_gathered_assertion"
+          algorithm: ""
+          hash: "hello"
+        }
+        label: "c2pa.claim.v2"
+        redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion"
+      )pb"))));
+}
+
+TEST(ValidateClaimTest, ClaimV2HasNoGeneratorInfo) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "dc:format": "image/jpeg",
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_created_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  SignatureInfo signature_info;
+  signature_info.set_issuer("fake_issuer");
+  signature_info.set_cert_serial_number("1234");
+  CoseVerificationResult verification_result;
+  *verification_result.mutable_signature_info() = signature_info;
+  EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimMalformed));
+  EXPECT_THAT(
+      result, Optional(EqualsProto(ParseTextProtoOrDie<Claim>(R"pb(
+        instance_id: "1234"
+        label: "c2pa.claim.v2"
+        signature_info { cert_serial_number: "1234" issuer: "fake_issuer" }
+        claim_generator_info {}
+        created_assertions {
+          url: "self#jumbf=c2pa.some_created_assertion"
+          algorithm: ""
+          hash: "hello"
+        }
+        gathered_assertions {
+          url: "self#jumbf=c2pa.some_gathered_assertion"
+          algorithm: ""
+          hash: "hello"
+        }
+        redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion"
+      )pb"))));
+}
+
+TEST(ValidateClaimTest, ClaimV2ProvidedInLegacyBox) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_created_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(
+      t.GetFailures(),
+      ContainsFailure(FailureStatusCode::kGoogleUnsupportedSpecVersion));
+}
+
+TEST(ValidateClaimTest, ClaimV2WithDcFormat) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "dc:format": "image/jpeg",
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_created_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  SignatureInfo signature_info;
+  signature_info.set_issuer("fake_issuer");
+  signature_info.set_cert_serial_number("1234");
+  CoseVerificationResult verification_result;
+  *verification_result.mutable_signature_info() = signature_info;
+  EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  // We ignore `dc:format` fields in non-legacy claim boxes.
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+}
+
+TEST(ValidateClaimTest, ClaimV2LegacyAssertionsIgnored) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "assertions": [
+      {"url": "self#jumbf=c2pa.some_created_assertion", "hash": "b64'aGVsbG8'"},
+      {"url": "self#jumbf=c2pa.some_gathered_assertion", "hash": "b64'aGVsbG8'"}
+    ],
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_created_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  SignatureInfo signature_info;
+  signature_info.set_issuer("fake_issuer");
+  signature_info.set_cert_serial_number("1234");
+  CoseVerificationResult verification_result;
+  *verification_result.mutable_signature_info() = signature_info;
+  EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  ASSERT_TRUE(result.has_value());
+  EXPECT_THAT(result->created_assertions(), SizeIs(1));
+  EXPECT_THAT(result->gathered_assertions(), SizeIs(1));
+}
+
+TEST(ValidateClaimTest, ClaimV2MissingSignature) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  SignatureInfo signature_info;
+  signature_info.set_issuer("fake_issuer");
+  signature_info.set_cert_serial_number("1234");
+  CoseVerificationResult verification_result;
+  *verification_result.mutable_signature_info() = signature_info;
+  EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimMalformed));
+}
+
+TEST(ValidateClaimTest, ClaimV2MissingCreatedAssertions) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  SignatureInfo signature_info;
+  signature_info.set_issuer("fake_issuer");
+  signature_info.set_cert_serial_number("1234");
+  CoseVerificationResult verification_result;
+  *verification_result.mutable_signature_info() = signature_info;
+  EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimMalformed));
+}
+
+TEST(ValidateClaimTest, ClaimV2Ok) {
+  auto manifest = CreateManifestBuilder();
+  absl::string_view claim_json = R"json({
+    "instanceID": "1234",
+    "signature": "self#jumbf=c2pa.signature",
+    "signature_info": {
+      "cert_serial_number": "1234",
+      "issuer": "fake_issuer"
+    },
+    "claim_generator_info": {
+      "name": "test_generator",
+      "version": "1.0",
+      "icon": {
+        "url": "some_icon_link",
+        "hash": "b64'aGVsbG8'"
+      },
+      "operating_system": "linux"
+    },
+    "created_assertions": [{
+      "url": "self#jumbf=c2pa.some_created_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "gathered_assertions": [{
+      "url": "self#jumbf=c2pa.some_gathered_assertion",
+      "hash": "b64'aGVsbG8'"
+    }],
+    "redacted_assertions": ["self#jumbf=c2pa.some_redacted_assertion"]
+  })json";
+  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromJson(claim_json)), IsOk());
+  ASSERT_THAT(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
+      IsOk());
+  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  MockCoseVerifier mock_cose_verifier;
+  SignatureInfo signature_info;
+  signature_info.set_issuer("fake_issuer");
+  signature_info.set_cert_serial_number("1234");
+  CoseVerificationResult verification_result;
+  *verification_result.mutable_signature_info() = signature_info;
+  EXPECT_CALL(mock_cose_verifier, Verify).WillOnce(Return(verification_result));
+  TestValidationTracker t;
+  ClaimValidatorImpl validator(&mock_cose_verifier);
+  auto result =
+      ValidateWithBuilder(validator, std::move(manifest), t.tracker());
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  EXPECT_THAT(
+      result, Optional(EqualsProto(ParseTextProtoOrDie<Claim>(R"pb(
+        instance_id: "1234"
+        claim_generator_info {
+          name: "test_generator"
+          version: "1.0"
+          icon { url: "some_icon_link" hash: "hello" }
+          operating_system: "linux"
+        }
+        signature_info { cert_serial_number: "1234" issuer: "fake_issuer" }
+        created_assertions {
+          url: "self#jumbf=c2pa.some_created_assertion"
+          algorithm: ""
+          hash: "hello"
+        }
+        gathered_assertions {
+          url: "self#jumbf=c2pa.some_gathered_assertion"
+          algorithm: ""
+          hash: "hello"
+        }
+        redacted_assertions: "self#jumbf=c2pa.some_redacted_assertion"
+        label: "c2pa.claim.v2"
+      )pb"))));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/constants/BUILD b/constants/BUILD
new file mode 100644
index 0000000..657d09e
--- /dev/null
+++ b/constants/BUILD
@@ -0,0 +1,50 @@
+# 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.
+#
+
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "status_codes",
+    hdrs = ["status_codes.h"],
+    deps = ["@abseil-cpp//absl/strings"],
+)
+
+cc_library(
+    name = "labels",
+    hdrs = ["labels.h"],
+    deps = [
+        "//jumbf:constants",
+        "//uuid",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "ingredient_relationships",
+    hdrs = ["ingredient_relationships.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "spec_version",
+    hdrs = ["spec_version.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
diff --git a/constants/ingredient_relationships.h b/constants/ingredient_relationships.h
new file mode 100644
index 0000000..f05de07
--- /dev/null
+++ b/constants/ingredient_relationships.h
@@ -0,0 +1,32 @@
+// 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_CONSTANTS_INGREDIENT_RELATIONSHIPS_H_
+#define THIRD_PARTY_CREDENTIO_CONSTANTS_INGREDIENT_RELATIONSHIPS_H_
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Ingredient relationships see
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_relationship.
+inline constexpr absl::string_view kIngredientRelationshipParentOf = "parentOf";
+inline constexpr absl::string_view kIngredientRelationshipComponentOf =
+    "componentOf";
+inline constexpr absl::string_view kIngredientRelationshipInputTo = "inputTo";
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CONSTANTS_INGREDIENT_RELATIONSHIPS_H_
diff --git a/constants/labels.h b/constants/labels.h
new file mode 100644
index 0000000..748d9ae
--- /dev/null
+++ b/constants/labels.h
@@ -0,0 +1,134 @@
+// 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_CONSTANTS_LABELS_H_
+#define THIRD_PARTY_CREDENTIO_CONSTANTS_LABELS_H_
+
+#include <cstdint>
+
+#include "absl/strings/string_view.h"
+#include "jumbf/constants.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+
+// Delimiter used to separate the various components of a manifest label.
+inline constexpr absl::string_view kManifestLabelDelimiter = "/";
+
+// Minimum Jumbf description toggles required to support C2PA. See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_labels_2.
+inline constexpr uint8_t kMinimumJumbfDescriptionToggles =
+    jumbf::kDescriptionToggleLabelPresent |
+    jumbf::kDescriptionToggleRequestable;
+
+// Box label and UUID of the C2PA Manifest Store box. See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_manifest_store.
+inline constexpr absl::string_view kManifestStoreLabel = "c2pa";
+inline constexpr absl::string_view kManifestStoreUrlPrefix =
+    "self#jumbf=/c2pa/";
+inline constexpr Uuid kManifestStoreUuid(
+    Uuid::FromStringOrDie("63327061-0011-0010-8000-00AA00389B71"));
+
+// Box UUIDs of the C2PA Manifest box. See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_manifest_store
+inline constexpr Uuid kStandardManifestUuid(
+    Uuid::FromStringOrDie("63326D61-0011-0010-8000-00AA00389B71"));
+inline constexpr Uuid kCompressedManifestUuid(
+    Uuid::FromStringOrDie("6332636D-0011-0010-8000-00AA00389B71"));
+inline constexpr Uuid kUpdateManifestUuid(
+    Uuid::FromStringOrDie("6332756D-0011-0010-8000-00AA00389B71"));
+inline constexpr Uuid kTimestampManifestUuid(
+    Uuid::FromStringOrDie("6332746D-0011-0010-8000-00AA00389B71"));
+
+// Box labels and UUID of the C2PA Claim box.
+// The deprecated (v1) format shares the same UUID as the current (v2) format.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_claim_and_claim_signature.
+inline constexpr absl::string_view kClaimV1Label = "c2pa.claim";
+inline constexpr absl::string_view kClaimV2Label = "c2pa.claim.v2";
+inline constexpr Uuid kClaimUuid(
+    Uuid::FromStringOrDie("6332636C-0011-0010-8000-00AA00389B71"));
+
+// Box label and UUID of the C2PA Claim Signature box.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_claim_and_claim_signature.
+inline constexpr absl::string_view kClaimSignatureLabel = "c2pa.signature";
+inline constexpr absl::string_view kClaimSignatureUrl =
+    "self#jumbf=c2pa.signature";
+inline constexpr Uuid kClaimSignatureUuid(
+    Uuid::FromStringOrDie("63326373-0011-0010-8000-00AA00389B71"));
+
+// Box label and UUID of the C2PA Assertion Store box. See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_assertion_store_2.
+inline constexpr absl::string_view kAssertionStoreLabel = "c2pa.assertions";
+inline constexpr absl::string_view kAssertionStoreUrlPrefix =
+    "self#jumbf=c2pa.assertions/";
+inline constexpr Uuid kAssertionStoreUuid(
+    Uuid::FromStringOrDie("63326173-0011-0010-8000-00AA00389B71"));
+
+// C2PA assertion labels.
+inline constexpr absl::string_view kActionsAssertionV2Label = "c2pa.actions.v2";
+inline constexpr absl::string_view kActionsAssertionV1Label = "c2pa.actions";
+inline constexpr absl::string_view kBmffBasedHashAssertionV2Label =
+    "c2pa.hash.bmff.v2";
+inline constexpr absl::string_view kBmffBasedHashAssertionV3Label =
+    "c2pa.hash.bmff.v3";
+inline constexpr absl::string_view kDataHashAssertionLabel = "c2pa.hash.data";
+inline constexpr absl::string_view kBoxesHashAssertionLabel = "c2pa.hash.boxes";
+inline constexpr absl::string_view kCollectionDataHashAssertionLabel =
+    "c2pa.hash.collection.data";
+inline constexpr absl::string_view kBmffBasedHashAssertionV2PartLabel =
+    "c2pa.hash.bmff.v2.part";
+inline constexpr absl::string_view kBmffBasedHashAssertionV3PartLabel =
+    "c2pa.hash.bmff.v3.part";
+inline constexpr absl::string_view kDataHashAssertionPartLabel =
+    "c2pa.hash.data.part";
+inline constexpr absl::string_view kBoxesHashAssertionPartLabel =
+    "c2pa.hash.boxes.part";
+inline constexpr absl::string_view kCollectionDataHashAssertionPartLabel =
+    "c2pa.hash.collection.data.part";
+inline constexpr absl::string_view kMultiAssetHashAssertionLabel =
+    "c2pa.hash.multi-asset";
+inline constexpr absl::string_view kIngredientAssertionV1Label =
+    "c2pa.ingredient";
+inline constexpr absl::string_view kIngredientAssertionV2Label =
+    "c2pa.ingredient.v2";
+inline constexpr absl::string_view kIngredientAssertionV3Label =
+    "c2pa.ingredient.v3";
+inline constexpr absl::string_view kGDepthAssertionLabel =
+    "c2pa.depthmap.GDepth";
+inline constexpr absl::string_view kAiDisclosureAssertionLabel =
+    "c2pa.ai-disclosure";
+inline constexpr absl::string_view kEnvironmentalSustainabilityAssertionLabel =
+    "c2pa.environmental-sustainability";
+inline constexpr absl::string_view kSoftBindingAssertionLabel =
+    "c2pa.soft-binding";
+inline constexpr absl::string_view kRepositoryReceiptAssertionLabel =
+    "c2pa.repository-receipt";
+inline constexpr absl::string_view kThumbnailClaimAssertionLabelPrefix =
+    "c2pa.thumbnail.claim.";
+inline constexpr absl::string_view kThumbnailIngredientAssertionLabelPrefix =
+    "c2pa.thumbnail.ingredient.";
+inline constexpr absl::string_view kMetadataAssertionLabelSuffix = ".metadata";
+
+// The UUID of the C2PA BMFF box is D8FEC3D6-1B0E-483C-9297-5828877EC481.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_definition.
+inline constexpr absl::string_view kC2paBmffBoxUuid(
+    "\xD8\xFE\xC3\xD6\x1B\x0E\x48\x3C\x92\x97\x58\x28\x87\x7E\xC4\x81", 16);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CONSTANTS_LABELS_H_
diff --git a/constants/spec_version.h b/constants/spec_version.h
new file mode 100644
index 0000000..348c374
--- /dev/null
+++ b/constants/spec_version.h
@@ -0,0 +1,42 @@
+// 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_CONSTANTS_SPEC_VERSION_H_
+#define THIRD_PARTY_CREDENTIO_CONSTANTS_SPEC_VERSION_H_
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+enum class SpecVersion {
+  kUnspecified = 0,
+  kC2pa_2_2 = 20200,  // C2PA 2.2.0
+  kC2pa_2_4 = 20400,  // C2PA 2.4.0
+};
+
+inline absl::string_view SpecVersionToString(SpecVersion version) {
+  switch (version) {
+    case SpecVersion::kC2pa_2_2:
+      return "2.2.0";
+    case SpecVersion::kC2pa_2_4:
+      return "2.4.0";
+    default:
+      return "";
+  }
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CONSTANTS_SPEC_VERSION_H_
diff --git a/constants/status_codes.h b/constants/status_codes.h
new file mode 100644
index 0000000..1c70668
--- /dev/null
+++ b/constants/status_codes.h
@@ -0,0 +1,510 @@
+// 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_CONSTANTS_STATUS_CODES_H_
+#define THIRD_PARTY_CREDENTIO_CONSTANTS_STATUS_CODES_H_
+
+#include "absl/strings/str_cat.h"
+
+namespace credentio {
+
+// Constants corresponding to C2PA status codes.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_standard_status_codes
+// for more information. about the C2PA status codes. Integer ranges for
+// success/informational/failure are disjoint to be safe.
+
+// Success status codes.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_success_codes.
+enum class SuccessStatusCode {
+  kClaimSignatureValidated = 201,  // claimSignature.validated
+  kClaimSignatureInsideValidity,   // claimSignature.insideValidity
+
+  // Custom success codes, specific to this validator.
+  kGoogleAssertionDataHashMatch,   // com.google.assertion.dataHash.match
+  kGoogleAssertionBmffHashMatch,   // com.google.assertion.bmffHash.match
+  kGoogleAssertionBoxesHashMatch,  // com.google.assertion.boxesHash.match
+  // com.google.assertion.multiAssetHash.match
+  kGoogleAssertionMultiAssetHashMatch,
+  // com.google.assertion.collectionHash.match
+  kGoogleAssertionCollectionHashMatch,
+  kAssertionDataHashMatch,             // assertion.dataHash.match
+  kIngredientClaimSignatureValidated,  // ingredient.claimSignature.validated
+  kIngredientManifestValidated,        // ingredient.manifest.validated
+  kAssertionHashedUriMatch,            // assertion.hashedURI.match
+  kAssertionBmffHashMatch,             // assertion.bmffHash.match
+  kAssertionBoxesHashMatch,            // assertion.boxesHash.match
+  kAssertionCollectionHashMatch,       // assertion.collectionHash.match
+  kAssertionMultiAssetHashMatch,       // assertion.multiAssetHash.match
+  kSigningCredentialTrusted,           // signingCredential.trusted
+  kTimestampTrusted,                   // timeStamp.trusted
+  kTimestampValidated,                 // timeStamp.validated
+  kSigningCredentialOcspNotRevoked,    // signingCredential.ocsp.notRevoked
+};
+
+// Informational status codes.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_informational_codes.
+enum class InformationalStatusCode {
+  // Custom informational codes, specific to this validator
+  // go/keep-sorted start
+  // Indicates that the OCSP response's `producedAt` timestamp is outside of the
+  // certificate's validity period or the timestamp's time is outside of the
+  // validity window of the stapled OCSP response.
+  kSigningCredentialOcspOutsideValidity = 601,
+  // com.google.signingCredential.ocsp.outsideValidity
+  // Indicates that the timestamp's time is outside of the range that can be
+  // handled by this validator. The timestamp may be considered valid by
+  // other implementations.
+  kTimestampOutOfRange,          // com.google.timeStamp.outOfRange
+  kTimestampV1FormatInV2Header,  // com.google.timeStamp.V1FormatInV2Header
+  kTimestampV2MessageImprintInV1Format,
+  // com.google.timeStamp.V2MessageImprintInV1Format
+  // go/keep-sorted end
+
+  // Standard informational codes
+  // go/keep-sorted start
+  kIngredientUnknownProvenance,   // ingredient.unknownProvenance
+  kSigningCredentialOcspSkipped,  // signingCredential.ocsp.skipped
+  kTimestampMalformed,            // timeStamp.malformed
+  kTimestampMismatch,             // timeStamp.mismatch
+  kTimestampOutsideValidity,      // timeStamp.outsideValidity
+  kTimestampUntrusted,            // timeStamp.untrusted
+  // go/keep-sorted end
+
+  // Assertion informational codes
+  // go/keep-sorted start
+  // assertion.bmffHash.additionalExclusionsPresent
+  kAssertionBmffHashAdditionalExclusionsPresent,
+  // assertion.boxesHash.additionalExclusionsPresent
+  kAssertionBoxesHashAdditionalExclusionsPresent,
+  // assertion.dataHash.additionalExclusionsPresent
+  kAssertionDataHashAdditionalExclusionsPresent,
+  // go/keep-sorted end
+};
+
+// Failure status codes.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_failure_codes.
+enum class FailureStatusCode {
+  kGeneralError = 401,  // (custom, name TBD)
+
+  // Custom failure codes, specific to this validator.
+  kGoogleInternalError,            // com.google.internal.error
+  kGoogleUnsupportedSpecVersion,   // com.google.unsupportedSpecVersion
+  kGoogleUnsupportedManifestType,  // com.google.unsupportedManifestType
+  // com.google.assertion.action.unknownAction
+  kGoogleAssertionActionUnknownAction,
+  kGoogleAssertionDataHashMismatch,   // com.google.assertion.dataHash.mismatch
+  kGoogleAssertionDataHashMalformed,  // com.google.assertion.dataHash.malformed
+  kGoogleAssertionBmffHashMalformed,  // com.google.assertion.bmffHash.malformed
+  kGoogleAssertionBmffHashMismatch,   // com.google.assertion.bmffHash.mismatch
+  // com.google.assertion.boxesHash.unknownBox
+  kGoogleAssertionBoxesHashUnknownBox,
+  kGoogleAssertionBoxesHashMismatch,  // com.google.assertion.boxesHash.mismatch
+  // com.google.assertion.boxesHash.malformed
+  kGoogleAssertionBoxesHashMalformed,
+  // com.google.assertion.multiAssetHash.mismatch
+  kGoogleAssertionMultiAssetHashMismatch,
+  // com.google.assertion.multiAssetHash.malformed
+  kGoogleAssertionMultiAssetHashMalformed,
+  // com.google.assertion.multiAssetHash.missingPart
+  kGoogleAssertionMultiAssetHashMissingPart,
+  kGoogleHashedUriUnsupported,  // com.google.hashedUri.unsupported
+
+  // Failure codes used for C2PA Claim validation.
+  kAlgorithmUnsupported,           // algorithm.unsupported
+  kClaimHardBindingsMissing,       // claim.hardBindings.missing
+  kClaimMalformed,                 // claim.malformed
+  kClaimMissing,                   // claim.missing
+  kClaimMultiple,                  // claim.multiple
+  kClaimCborInvalid,               // claim.cbor.invalid
+  kClaimSignatureMissing,          // claimSignature.missing
+  kClaimSignatureMismatch,         // claimSignature.mismatch
+  kClaimSignatureOutsideValidity,  // claimSignature.outsideValidity
+  kHashedUriMissing,               // hashedUri.missing
+  kSigningCredentialInvalid,       // signingCredential.invalid
+  kSigningCredentialOcspRevoked,   // signingCredential.ocsp.revoked
+  kSigningCredentialUntrusted,     // signingCredential.untrusted
+
+  // Failure codes used for C2PA Assertion validation.
+  kAssertionCborInvalid,                // assertion.cbor.invalid
+  kAssertionJsonInvalid,                // assertion.json.invalid
+  kAssertionMultipleHardBindings,       // assertion.multipleHardBindings
+  kAssertionDataHashMismatch,           // assertion.dataHash.mismatch
+  kAssertionDataHashMalformed,          // assertion.dataHash.malformed
+  kAssertionUndeclared,                 // assertion.undeclared
+  kAssertionOutsideManifest,            // assertion.outsideManifest
+  kAssertionMissing,                    // assertion.missing
+  kAssertionHashedUriMismatch,          // assertion.hashedURI.mismatch
+  kAssertionNotRedacted,                // assertion.notRedacted
+  kAssertionSelfRedacted,               // assertion.selfRedacted
+  kAssertionBmffHashMalformed,          // assertion.bmffHash.malformed
+  kAssertionBmffHashMismatch,           // assertion.bmffHash.mismatch
+  kAssertionBoxesHashUnknownBox,        // assertion.boxesHash.unknownBox
+  kAssertionBoxesHashMismatch,          // assertion.boxesHash.mismatch
+  kAssertionBoxesHashMalformed,         // assertion.boxesHash.malformed
+  kAssertionMultiAssetHashMismatch,     // assertion.multiAssetHash.mismatch
+  kAssertionMultiAssetHashMalformed,    // assertion.multiAssetHash.malformed
+  kAssertionMultiAssetHashMissingPart,  // assertion.multiAssetHash.missingPart
+  // assertion.collectionHash.incorrectFileCount
+  kAssertionCollectionHashIncorrectFileCount,
+  // assertion.collectionHash.invalidURI
+  kAssertionCollectionHashInvalidUri,
+  kAssertionCollectionHashMismatch,    // assertion.collectionHash.mismatch
+  kAssertionCollectionHashMalformed,   // assertion.collectionHash.malformed
+  kAssertionActionRedacted,            // assertion.action.redacted
+  kAssertionActionMalformed,           // assertion.action.malformed
+  kAssertionActionSoftBindingMissing,  // assertion.action.softBindingMissing
+  kAssertionActionIngredientMismatch,  // assertion.action.ingredientMismatch
+  kAssertionActionRedactionMismatch,   // assertion.action.redactionMismatch
+  kAssertionIngredientMalformed,       // assertion.ingredient.malformed
+  kHashedUriMismatch,                  // hashedUri.mismatch
+
+  // Failure codes related to C2PA ingredient manifests
+  kIngredientClaimSignatureMissing,   // ingredient.claimSignature.missing
+  kIngredientClaimSignatureMismatch,  // ingredient.claimSignature.mismatch
+  kIngredientManifestMissing,         // ingredient.manifest.missing
+  kIngredientManifestMismatch,        // ingredient.manifest.mismatch
+
+  kManifestMultipleParents,     // manifest.multipleParents
+  kManifestUpdateInvalid,       // manifest.update.invalid
+  kManifestUpdateWrongParents,  // manifest.update.wrongParents
+};
+
+template <typename Sink>
+void AbslStringify(Sink& sink, SuccessStatusCode status) {
+  switch (status) {
+    case SuccessStatusCode::kClaimSignatureValidated:
+      sink.Append("claimSignature.validated");
+      return;
+    case SuccessStatusCode::kClaimSignatureInsideValidity:
+      sink.Append("claimSignature.insideValidity");
+      return;
+    case SuccessStatusCode::kAssertionDataHashMatch:
+      sink.Append("assertion.dataHash.match");
+      return;
+    case SuccessStatusCode::kIngredientManifestValidated:
+      sink.Append("ingredient.manifest.validated");
+      return;
+    case SuccessStatusCode::kIngredientClaimSignatureValidated:
+      sink.Append("ingredient.claimSignature.validated");
+      return;
+    case SuccessStatusCode::kAssertionHashedUriMatch:
+      sink.Append("assertion.hashedURI.match");
+      return;
+    case SuccessStatusCode::kAssertionBmffHashMatch:
+      sink.Append("assertion.bmffHash.match");
+      return;
+    case SuccessStatusCode::kAssertionBoxesHashMatch:
+      sink.Append("assertion.boxesHash.match");
+      return;
+    case SuccessStatusCode::kAssertionCollectionHashMatch:
+      sink.Append("assertion.collectionHash.match");
+      return;
+    case SuccessStatusCode::kSigningCredentialTrusted:
+      sink.Append("signingCredential.trusted");
+      return;
+    case SuccessStatusCode::kTimestampTrusted:
+      sink.Append("timeStamp.trusted");
+      return;
+    case SuccessStatusCode::kTimestampValidated:
+      sink.Append("timeStamp.validated");
+      return;
+    case SuccessStatusCode::kSigningCredentialOcspNotRevoked:
+      sink.Append("signingCredential.ocsp.notRevoked");
+      return;
+    case SuccessStatusCode::kGoogleAssertionDataHashMatch:
+      sink.Append("com.google.assertion.dataHash.match");
+      return;
+    case SuccessStatusCode::kGoogleAssertionCollectionHashMatch:
+      sink.Append("com.google.assertion.collectionHash.match");
+      return;
+    case SuccessStatusCode::kGoogleAssertionBmffHashMatch:
+      sink.Append("com.google.assertion.bmffHash.match");
+      return;
+    case SuccessStatusCode::kGoogleAssertionBoxesHashMatch:
+      sink.Append("com.google.assertion.boxesHash.match");
+      return;
+    case SuccessStatusCode::kGoogleAssertionMultiAssetHashMatch:
+      sink.Append("com.google.assertion.multiAssetHash.match");
+      return;
+    case SuccessStatusCode::kAssertionMultiAssetHashMatch:
+      sink.Append("assertion.multiAssetHash.match");
+      return;
+  }
+  sink.Append(
+      absl::StrCat("UNKNOWN_SUCCESS_STATUS_", static_cast<int>(status)));
+}
+
+template <typename Sink>
+void AbslStringify(Sink& sink, InformationalStatusCode status) {
+  switch (status) {
+    case credentio::InformationalStatusCode::
+        kSigningCredentialOcspOutsideValidity:
+      sink.Append("com.google.signingCredential.ocsp.outsideValidity");
+      return;
+    case credentio::InformationalStatusCode::kTimestampOutOfRange:
+      sink.Append("com.google.timeStamp.outOfRange");
+      return;
+    case InformationalStatusCode::kTimestampV1FormatInV2Header:
+      sink.Append("com.google.timeStamp.V1FormatInV2Header");
+      return;
+    case InformationalStatusCode::kTimestampV2MessageImprintInV1Format:
+      sink.Append("com.google.timeStamp.V2MessageImprintInV1Format");
+      return;
+    case InformationalStatusCode::kIngredientUnknownProvenance:
+      sink.Append("ingredient.unknownProvenance");
+      return;
+    case InformationalStatusCode::kTimestampMalformed:
+      sink.Append("timeStamp.malformed");
+      return;
+    case InformationalStatusCode::kTimestampUntrusted:
+      sink.Append("timeStamp.untrusted");
+      return;
+    case credentio::InformationalStatusCode::kTimestampOutsideValidity:
+      sink.Append("timeStamp.outsideValidity");
+      return;
+    case InformationalStatusCode::kSigningCredentialOcspSkipped:
+      sink.Append("signingCredential.ocsp.skipped");
+      return;
+    case InformationalStatusCode::kTimestampMismatch:
+      sink.Append("timeStamp.mismatch");
+      return;
+    case InformationalStatusCode::kAssertionDataHashAdditionalExclusionsPresent:
+      sink.Append("assertion.dataHash.additionalExclusionsPresent");
+      return;
+    case InformationalStatusCode::
+        kAssertionBoxesHashAdditionalExclusionsPresent:
+      sink.Append("assertion.boxesHash.additionalExclusionsPresent");
+      return;
+    case InformationalStatusCode::kAssertionBmffHashAdditionalExclusionsPresent:
+      sink.Append("assertion.bmffHash.additionalExclusionsPresent");
+      return;
+  }
+  sink.Append(
+      absl::StrCat("UNKNOWN_INFORMATIONAL_STATUS_", static_cast<int>(status)));
+}
+
+template <typename Sink>
+void AbslStringify(Sink& sink, FailureStatusCode status) {
+  switch (status) {
+    case FailureStatusCode::kGeneralError:
+      sink.Append("general.error");
+      return;
+    case FailureStatusCode::kGoogleInternalError:
+      sink.Append("com.google.internal.error");
+      return;
+    case FailureStatusCode::kGoogleUnsupportedSpecVersion:
+      sink.Append("com.google.unsupportedSpecVersion");
+      return;
+    case FailureStatusCode::kGoogleUnsupportedManifestType:
+      sink.Append("com.google.unsupportedManifestType");
+      return;
+    case FailureStatusCode::kClaimMissing:
+      sink.Append("claim.missing");
+      return;
+    case FailureStatusCode::kClaimMultiple:
+      sink.Append("claim.multiple");
+      return;
+    case FailureStatusCode::kClaimCborInvalid:
+      sink.Append("claim.cbor.invalid");
+      return;
+    case FailureStatusCode::kClaimSignatureMissing:
+      sink.Append("claimSignature.missing");
+      return;
+    case FailureStatusCode::kClaimSignatureMismatch:
+      sink.Append("claimSignature.mismatch");
+      return;
+    case FailureStatusCode::kClaimSignatureOutsideValidity:
+      sink.Append("claimSignature.outsideValidity");
+      return;
+    case FailureStatusCode::kHashedUriMissing:
+      sink.Append("hashedUri.missing");
+      return;
+    case FailureStatusCode::kSigningCredentialInvalid:
+      sink.Append("signingCredential.invalid");
+      return;
+    case FailureStatusCode::kSigningCredentialUntrusted:
+      sink.Append("signingCredential.untrusted");
+      return;
+    case FailureStatusCode::kAssertionCborInvalid:
+      sink.Append("assertion.cbor.invalid");
+      return;
+    case FailureStatusCode::kAssertionJsonInvalid:
+      sink.Append("assertion.json.invalid");
+      return;
+    case FailureStatusCode::kClaimHardBindingsMissing:
+      sink.Append("claim.hardBindings.missing");
+      return;
+    case FailureStatusCode::kClaimMalformed:
+      sink.Append("claim.malformed");
+      return;
+    case FailureStatusCode::kAssertionMultipleHardBindings:
+      sink.Append("assertion.multipleHardBindings");
+      return;
+    case FailureStatusCode::kAssertionDataHashMismatch:
+      sink.Append("assertion.dataHash.mismatch");
+      return;
+    case FailureStatusCode::kAlgorithmUnsupported:
+      sink.Append("algorithm.unsupported");
+      return;
+    case FailureStatusCode::kAssertionDataHashMalformed:
+      sink.Append("assertion.dataHash.malformed");
+      return;
+    case FailureStatusCode::kAssertionUndeclared:
+      sink.Append("assertion.undeclared");
+      return;
+    case FailureStatusCode::kAssertionOutsideManifest:
+      sink.Append("assertion.outsideManifest");
+      return;
+    case FailureStatusCode::kAssertionMissing:
+      sink.Append("assertion.missing");
+      return;
+    case FailureStatusCode::kIngredientClaimSignatureMissing:
+      sink.Append("ingredient.claimSignature.missing");
+      return;
+    case FailureStatusCode::kIngredientClaimSignatureMismatch:
+      sink.Append("ingredient.claimSignature.mismatch");
+      return;
+    case FailureStatusCode::kIngredientManifestMissing:
+      sink.Append("ingredient.manifest.missing");
+      return;
+    case FailureStatusCode::kIngredientManifestMismatch:
+      sink.Append("ingredient.manifest.mismatch");
+      return;
+    case FailureStatusCode::kAssertionHashedUriMismatch:
+      sink.Append("assertion.hashedURI.mismatch");
+      return;
+    case FailureStatusCode::kAssertionNotRedacted:
+      sink.Append("assertion.notRedacted");
+      return;
+    case FailureStatusCode::kAssertionSelfRedacted:
+      sink.Append("assertion.selfRedacted");
+      return;
+    case FailureStatusCode::kAssertionBmffHashMalformed:
+      sink.Append("assertion.bmffHash.malformed");
+      return;
+    case FailureStatusCode::kAssertionBmffHashMismatch:
+      sink.Append("assertion.bmffHash.mismatch");
+      return;
+    case FailureStatusCode::kAssertionBoxesHashUnknownBox:
+      sink.Append("assertion.boxesHash.unknownBox");
+      return;
+    case FailureStatusCode::kAssertionBoxesHashMismatch:
+      sink.Append("assertion.boxesHash.mismatch");
+      return;
+    case FailureStatusCode::kGoogleAssertionActionUnknownAction:
+      sink.Append("com.google.assertion.action.unknownAction");
+      return;
+    case FailureStatusCode::kAssertionActionRedacted:
+      sink.Append("assertion.action.redacted");
+      return;
+    case FailureStatusCode::kAssertionActionMalformed:
+      sink.Append("assertion.action.malformed");
+      return;
+    case FailureStatusCode::kAssertionActionSoftBindingMissing:
+      sink.Append("assertion.action.softBindingMissing");
+      return;
+    case FailureStatusCode::kAssertionActionIngredientMismatch:
+      sink.Append("assertion.action.ingredientMismatch");
+      return;
+    case FailureStatusCode::kAssertionActionRedactionMismatch:
+      sink.Append("assertion.action.redactionMismatch");
+      return;
+    case FailureStatusCode::kAssertionIngredientMalformed:
+      sink.Append("assertion.ingredient.malformed");
+      return;
+    case FailureStatusCode::kHashedUriMismatch:
+      sink.Append("hashedUri.mismatch");
+      return;
+    case FailureStatusCode::kManifestMultipleParents:
+      sink.Append("manifest.multipleParents");
+      return;
+    case FailureStatusCode::kManifestUpdateInvalid:
+      sink.Append("manifest.update.invalid");
+      return;
+    case FailureStatusCode::kManifestUpdateWrongParents:
+      sink.Append("manifest.update.wrongParents");
+      return;
+    case FailureStatusCode::kSigningCredentialOcspRevoked:
+      sink.Append("signingCredential.ocsp.revoked");
+      return;
+    case FailureStatusCode::kGoogleAssertionDataHashMismatch:
+      sink.Append("com.google.assertion.dataHash.mismatch");
+      return;
+    case FailureStatusCode::kGoogleAssertionDataHashMalformed:
+      sink.Append("com.google.assertion.dataHash.malformed");
+      return;
+    case FailureStatusCode::kGoogleAssertionBmffHashMalformed:
+      sink.Append("com.google.assertion.bmffHash.malformed");
+      return;
+    case FailureStatusCode::kGoogleAssertionBmffHashMismatch:
+      sink.Append("com.google.assertion.bmffHash.mismatch");
+      return;
+    case FailureStatusCode::kGoogleAssertionBoxesHashUnknownBox:
+      sink.Append("com.google.assertion.boxesHash.unknownBox");
+      return;
+    case FailureStatusCode::kGoogleAssertionBoxesHashMismatch:
+      sink.Append("com.google.assertion.boxesHash.mismatch");
+      return;
+    case FailureStatusCode::kGoogleHashedUriUnsupported:
+      sink.Append("com.google.hashedUri.unsupported");
+      return;
+    case FailureStatusCode::kAssertionBoxesHashMalformed:
+      sink.Append("assertion.boxesHash.malformed");
+      return;
+    case FailureStatusCode::kGoogleAssertionBoxesHashMalformed:
+      sink.Append("com.google.assertion.boxesHash.malformed");
+      return;
+    case FailureStatusCode::kGoogleAssertionMultiAssetHashMismatch:
+      sink.Append("com.google.assertion.multiAssetHash.mismatch");
+      return;
+    case FailureStatusCode::kGoogleAssertionMultiAssetHashMalformed:
+      sink.Append("com.google.assertion.multiAssetHash.malformed");
+      return;
+    case FailureStatusCode::kGoogleAssertionMultiAssetHashMissingPart:
+      sink.Append("com.google.assertion.multiAssetHash.missingPart");
+      return;
+    case FailureStatusCode::kAssertionMultiAssetHashMismatch:
+      sink.Append("assertion.multiAssetHash.mismatch");
+      return;
+    case FailureStatusCode::kAssertionMultiAssetHashMalformed:
+      sink.Append("assertion.multiAssetHash.malformed");
+      return;
+    case FailureStatusCode::kAssertionMultiAssetHashMissingPart:
+      sink.Append("assertion.multiAssetHash.missingPart");
+      return;
+    case FailureStatusCode::kAssertionCollectionHashIncorrectFileCount:
+      sink.Append("assertion.collectionHash.incorrectFileCount");
+      return;
+    case FailureStatusCode::kAssertionCollectionHashInvalidUri:
+      sink.Append("assertion.collectionHash.invalidURI");
+      return;
+    case FailureStatusCode::kAssertionCollectionHashMismatch:
+      sink.Append("assertion.collectionHash.mismatch");
+      return;
+    case FailureStatusCode::kAssertionCollectionHashMalformed:
+      sink.Append("assertion.collectionHash.malformed");
+      return;
+  }
+  sink.Append(
+      absl::StrCat("UNKNOWN_FAILURE_STATUS_", static_cast<int>(status)));
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CONSTANTS_STATUS_CODES_H_
diff --git a/cose/BUILD b/cose/BUILD
new file mode 100644
index 0000000..1968d65
--- /dev/null
+++ b/cose/BUILD
@@ -0,0 +1,206 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "validation_status_util",
+    hdrs = ["validation_status_util.h"],
+    deps = [
+        "//constants:status_codes",
+        "//proto:validation_status_cc_proto",
+        "@abseil-cpp//absl/strings",
+    ],
+)
+
+cc_library(
+    name = "sig_structure",
+    srcs = ["sig_structure.cc"],
+    hdrs = ["sig_structure.h"],
+    deps = [
+        "//cbor",
+        "//cbor:parse",
+        "//crypto:algorithms",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:absl_log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@libcppbor",
+    ],
+)
+
+cc_test(
+    name = "sig_structure_test",
+    srcs = ["sig_structure_test.cc"],
+    deps = [
+        ":sig_structure",
+        "//cbor",
+        "//cbor:parse",
+        "//crypto:algorithms",
+        "//testing:cbor_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "ocsp_verifier",
+    srcs = ["ocsp_verifier.cc"],
+    hdrs = ["ocsp_verifier.h"],
+    deps = [
+        ":validation_status_util",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//crypto:crypto_read_handler",
+        "//proto:validation_status_cc_proto",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_test(
+    name = "ocsp_verifier_test",
+    srcs = ["ocsp_verifier_test.cc"],
+    deps = [
+        ":ocsp_verifier",
+        "//constants:status_codes",
+        "//crypto/default:pem",
+        "//proto:validation_status_cc_proto",
+        "//testing:pem",
+        "//tsp:verified_timestamp",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "cose_sign1",
+    srcs = ["cose_sign1.cc"],
+    hdrs = ["cose_sign1.h"],
+    deps = [
+        ":ocsp_verifier",
+        ":sig_structure",
+        ":validation_status_util",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//crypto:crypto_read_handler",
+        "//crypto:hash",
+        "//proto:assurance_level_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//tsp:timestamp_parsing",
+        "//tsp:verified_timestamp",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/memory",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/time:clock_interface",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+        "@protobuf//:timestamp_cc_proto",
+    ],
+)
+
+cc_library(
+    name = "verifier",
+    srcs = ["verifier.cc"],
+    hdrs = ["verifier.h"],
+    deps = [
+        ":cose_sign1",
+        ":sig_structure",
+        ":validation_status_util",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//crypto:crypto_read_handler",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//proto:cose_verification_result_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/time:clock_interface",
+        "@protobuf//:timestamp_cc_proto",
+    ],
+)
+
+cc_library(
+    name = "simple_cms_parser",
+    srcs = ["simple_cms_parser.cc"],
+    hdrs = ["simple_cms_parser.h"],
+    deps = [
+        "//crypto:algorithms",
+        "//crypto/default/cms:certificates",
+        "//crypto/default/cms:cms_parser",
+        "//crypto/default/cms:verify_signature",
+        "//tsp:cms_parser",
+        "//tsp:constants",
+        "//tsp:parsed_timestamp_token",
+        "@abseil-cpp//absl/algorithm:container",
+        "@abseil-cpp//absl/memory",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_test(
+    name = "simple_cms_parser_test",
+    srcs = ["simple_cms_parser_test.cc"],
+    deps = [
+        ":simple_cms_parser",
+        "//crypto:algorithms",
+        "//tsp:parsed_timestamp_token",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/cose/cose_sign1.cc b/cose/cose_sign1.cc
new file mode 100644
index 0000000..5af913c
--- /dev/null
+++ b/cose/cose_sign1.cc
@@ -0,0 +1,510 @@
+// 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 "cose/cose_sign1.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/log/check.h"
+#include "absl/log/log.h"
+#include "absl/memory/memory.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "absl/time/clock_interface.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "cose/ocsp_verifier.h"
+#include "cose/sig_structure.h"
+#include "cose/validation_status_util.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/hash.h"
+#include "google/protobuf/timestamp.pb.h"
+#include "proto/assurance_level.pb.h"
+#include "proto/signature_info.pb.h"
+#include "proto/validation_status.pb.h"
+#include "tsp/timestamp_parsing.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+namespace {
+
+std::vector<absl::string_view> StringViewVector(
+    absl::Span<const std::string> v) {
+  std::vector<absl::string_view> result;
+  result.reserve(v.size());
+  for (const auto& e : v) {
+    result.push_back(e);
+  }
+  return result;
+}
+
+// Validators shall accept the header from either the protected or unprotected
+// bucket, to maintain compatibility with previous versions of this
+// specification. In compliance with Section 14.2, “Identity of Signers”, if
+// this header appears in both the protected and unprotected buckets with the
+// same label, a validator shall reject the claim signature as malformed due to
+// the presence of multiple credentials.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#x509_certificates.
+absl::StatusOr<std::vector<std::string>> GetCertificateChain(
+    const ProtectedHeader& protected_header,
+    const UnprotectedHeader& unprotected_header) {
+  if (!protected_header.certificate_chain.empty() &&
+      !unprotected_header.certificate_chain.empty()) {
+    return absl::InvalidArgumentError(
+        "Certificate chain is present in both protected and unprotected "
+        "headers");
+  }
+  if (!protected_header.certificate_chain.empty()) {
+    return protected_header.certificate_chain;
+  }
+  return unprotected_header.certificate_chain;
+}
+
+absl::StatusOr<absl::string_view> ExtractTimestampHeaderValue(
+    const TstContainer& tst_container) {
+  // The `tstTokens` array is expected to contain a single token. If this is
+  // not the case, we need to return a `timeStamp.malformed` status and ignore
+  // the timestamp. See
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_time_stamp.
+  if (tst_container.tst_tokens.size() != 1) {
+    return absl::InvalidArgumentError(
+        "there are zero or multiple timestamp tokens in tstTokens");
+  }
+  return tst_container.tst_tokens[0].val;
+}
+
+std::string ConstructSigStructure(absl::string_view body_protected,
+                                  absl::string_view external_payload,
+                                  absl::string_view context) {
+  return EncodeSig1Structure(Sig1Structure{
+      .context = std::string(context),
+      .body_protected = std::string(body_protected),
+      .external_aad = "",
+      .payload = std::string(external_payload),
+  });
+}
+
+bool CheckMessageImprintHash(const HashCheckerFactory& hash_checker_factory,
+                             ValidationStatusSet* status_set,
+                             const VerifiedTimestamp& oldest_trusted_timestamp,
+                             absl::string_view timestamp_sig_structure) {
+  absl::StatusOr<std::unique_ptr<HashChecker>> messageimprint_hash_checker =
+      hash_checker_factory.Create(
+          oldest_trusted_timestamp.message_imprint_hash_algorithm());
+  if (!messageimprint_hash_checker.ok()) {
+    RecordStatus(status_set, InformationalStatusCode::kTimestampMalformed,
+                 {.url = kClaimSignatureLabel,
+                  .explanation = absl::StrCat(
+                      "cannot verify MessageImprint hash; failed to create "
+                      "hash checker: ",
+                      messageimprint_hash_checker.status())});
+    return false;
+  }
+  (*messageimprint_hash_checker)->Update(timestamp_sig_structure);
+  if (!(*messageimprint_hash_checker)
+           ->Check(oldest_trusted_timestamp.message_imprint_hash())) {
+    RecordStatus(status_set, InformationalStatusCode::kTimestampMismatch,
+                 {.url = kClaimSignatureLabel});
+    return false;
+  }
+  return true;
+}
+
+// The format of a timestamp.
+// See v1/v2 descriptions in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_storing_the_time_stamp
+enum class TimestampVersion {
+  // A timestamp in the "v1" format, normally found in `sigTst`.
+  kV1,
+  // A timestamp in the "v2" format, normally found in `sigTst2`.
+  kV2,
+};
+
+struct VersionedVerifiedTimestamp {
+  VerifiedTimestamp timestamp;
+  TimestampVersion version;
+};
+
+bool MessageImprintMatch(
+    absl::string_view& external_payload,
+    const HashCheckerFactory& hash_checker_factory,
+    ValidationStatusSet* status_set, absl::string_view protected_headers_bstr,
+    absl::string_view signature_bstr, absl::string_view signature,
+    const VersionedVerifiedTimestamp& oldest_trusted_timestamp) {
+  switch (oldest_trusted_timestamp.version) {
+    case TimestampVersion::kV1: {
+      ValidationStatusSet v1_status;
+      if (CheckMessageImprintHash(
+              hash_checker_factory, &v1_status,
+              oldest_trusted_timestamp.timestamp,
+              ConstructSigStructure(protected_headers_bstr, external_payload,
+                                    "CounterSignature"))) {
+        status_set->MergeFrom(v1_status);
+        return true;
+      }
+
+      ValidationStatusSet v2_status;
+      if (CheckMessageImprintHash(
+              hash_checker_factory, &v2_status,
+              oldest_trusted_timestamp.timestamp,
+              ConstructSigStructure(protected_headers_bstr, signature,
+                                    "CounterSignature"))) {
+        status_set->MergeFrom(v2_status);
+        RecordStatus(
+            status_set,
+            InformationalStatusCode::kTimestampV2MessageImprintInV1Format,
+            {.url = kClaimSignatureLabel});
+        return true;
+      }
+
+      status_set->MergeFrom(v1_status);
+      return false;
+    }
+    case TimestampVersion::kV2:
+      return CheckMessageImprintHash(
+          hash_checker_factory, status_set, oldest_trusted_timestamp.timestamp,
+          ConstructSigStructure(protected_headers_bstr, signature_bstr,
+                                "CounterSignature"));
+  }
+  LOG(DFATAL) << "Fell through end of exhaustive switch statement.";
+  return false;
+}
+
+[[nodiscard]] std::optional<SignatureInfo> RecordInvalidSigningCredential(
+    absl::string_view explanation, ValidationStatusSet* status_set) {
+  RecordStatus(status_set, FailureStatusCode::kSigningCredentialInvalid,
+               {.url = kClaimSignatureLabel, .explanation = explanation});
+  return std::nullopt;
+}
+
+InformationalStatusCode GetTimestampStatusCode(absl::StatusCode status_code) {
+  switch (status_code) {
+    case absl::StatusCode::kUnauthenticated:
+      return InformationalStatusCode::kTimestampUntrusted;
+    case absl::StatusCode::kOutOfRange:
+      return InformationalStatusCode::kTimestampOutsideValidity;
+    case absl::StatusCode::kInvalidArgument:
+    default:
+      return InformationalStatusCode::kTimestampMalformed;
+  }
+}
+
+absl::Status RecordInformationalAndReturnStatus(
+    absl::Status status, ValidationStatusSet* status_set) {
+  RecordStatus(status_set, GetTimestampStatusCode(status.code()),
+               {.url = kClaimSignatureLabel, .explanation = status.message()});
+  return status;
+}
+
+absl::StatusOr<VersionedVerifiedTimestamp> VerifyV1TimestampInternal(
+    absl::string_view timestamp_resp,
+    const CryptoReadHandler& crypto_read_handler) {
+  ABSL_ASSIGN_OR_RETURN(auto timestamp_token,
+                        ParseTimestampResp(timestamp_resp));
+  ABSL_ASSIGN_OR_RETURN(auto verified_timestamp,
+                        crypto_read_handler.VerifyTimestamp(timestamp_token));
+  return VersionedVerifiedTimestamp{.timestamp = std::move(verified_timestamp),
+                                    .version = TimestampVersion::kV1};
+}
+
+absl::StatusOr<VersionedVerifiedTimestamp> VerifyV1Timestamp(
+    absl::string_view timestamp_resp,
+    const CryptoReadHandler& crypto_read_handler,
+    ValidationStatusSet* status_set) {
+  auto result = VerifyV1TimestampInternal(timestamp_resp, crypto_read_handler);
+  if (!result.ok()) {
+    return RecordInformationalAndReturnStatus(result.status(), status_set);
+  }
+  return result;
+}
+
+absl::StatusOr<VersionedVerifiedTimestamp> VerifyV2Timestamp(
+    absl::string_view timestamp_token,
+    const CryptoReadHandler& crypto_read_handler,
+    ValidationStatusSet* status_set) {
+  absl::StatusOr<VerifiedTimestamp> default_attempt =
+      crypto_read_handler.VerifyTimestamp(timestamp_token);
+
+  if (default_attempt.ok()) {
+    return VersionedVerifiedTimestamp{.timestamp = *std::move(default_attempt),
+                                      .version = TimestampVersion::kV2};
+  }
+
+  absl::StatusOr<VersionedVerifiedTimestamp> fallback_attempt =
+      VerifyV1TimestampInternal(timestamp_token, crypto_read_handler);
+
+  if (fallback_attempt.ok()) {
+    RecordStatus(status_set,
+                 InformationalStatusCode::kTimestampV1FormatInV2Header,
+                 {.url = kClaimSignatureLabel});
+    return fallback_attempt;
+  }
+
+  return RecordInformationalAndReturnStatus(default_attempt.status(),
+                                            status_set);
+}
+
+// Returns the oldest valid timestamp found in the `sigTst2` or `sigTst`
+// header after building a trust chain from the certificate to an entry in the
+// validator's trust anchors.
+//
+// Returns `std::nullopt` if the timestamp header is not present.
+// Records a `timeStamp.untrusted` C2PA status to the tracker and returns
+// an `std::nullopt` if a trust chain cannot be built from the TSA's
+// certificate to one of the validator's trust anchors.
+std::optional<VersionedVerifiedTimestamp> VerifyAndGetOldestTrustedTimestamp(
+    const CryptoReadHandler& crypto_read_handler,
+    const UnprotectedHeader& unprotected_header,
+    ValidationStatusSet* status_set) {
+  bool is_v2 = true;
+  auto timestamp_container = unprotected_header.sig_tst2;
+  if (!timestamp_container.has_value()) {
+    is_v2 = false;
+    timestamp_container = unprotected_header.sig_tst;
+  }
+  if (!timestamp_container.has_value()) {
+    return std::nullopt;
+  }
+
+  auto timestamp_header_value =
+      ExtractTimestampHeaderValue(*timestamp_container);
+  if (!timestamp_header_value.ok()) {
+    DVLOG(1) << timestamp_header_value.status();
+    RecordStatus(status_set, InformationalStatusCode::kTimestampMalformed,
+                 {.url = kClaimSignatureLabel,
+                  .explanation = timestamp_header_value.status().ToString()});
+    return std::nullopt;
+  }
+
+  absl::StatusOr<VersionedVerifiedTimestamp> verified_timestamp =
+      is_v2 ? VerifyV2Timestamp(*timestamp_header_value, crypto_read_handler,
+                                status_set)
+            : VerifyV1Timestamp(*timestamp_header_value, crypto_read_handler,
+                                status_set);
+
+  if (!verified_timestamp.ok()) {
+    DVLOG(1) << verified_timestamp.status();
+    // VerifyV[12]Timestamp would have already reported any issues to `tracker`.
+    return std::nullopt;
+  }
+
+  return *verified_timestamp;
+}
+
+absl::StatusOr<google::protobuf::Timestamp> EncodeGoogleApiProto(absl::Time t) {
+  const int64_t s = absl::ToUnixSeconds(t);
+  if (s < -62135596800 || s > 253402300799) {
+    return absl::OutOfRangeError("Timestamp is out of range");
+  }
+  google::protobuf::Timestamp proto;
+  proto.set_seconds(s);
+  proto.set_nanos((t - absl::FromUnixSeconds(s)) / absl::Nanoseconds(1));
+  return proto;
+}
+
+}  // namespace
+
+std::unique_ptr<CoseSign1Verifier> CoseSign1Verifier::Create(
+    const CryptoReadHandler* absl_nonnull crypto_read_handler,
+    const HashCheckerFactory* absl_nonnull hash_checker_factory,
+    absl::Clock* absl_nonnull clock, Options options) {
+  return absl::WrapUnique(new CoseSign1Verifier(
+      crypto_read_handler, hash_checker_factory, clock, options));
+}
+
+std::optional<SignatureInfo> CoseSign1Verifier::Verify(
+    const CoseSign1TaggedStructure& cose_sign1,
+    absl::string_view external_payload, ValidationStatusSet* status_set) const {
+  // STEP 0: Get the certificate chain and parse the certificates.
+  auto protected_headers = DecodeProtectedHeader(cose_sign1.protected_header);
+  if (!protected_headers.ok()) {
+    return RecordInvalidSigningCredential(
+        absl::StrCat("cannot decode protected header: ",
+                     protected_headers.status().message()),
+        status_set);
+  }
+  auto certificate_chain =
+      GetCertificateChain(*protected_headers, cose_sign1.unprotected_header);
+  if (!certificate_chain.ok()) {
+    return RecordInvalidSigningCredential(certificate_chain.status().message(),
+                                          status_set);
+  }
+  if (certificate_chain->empty()) {
+    return RecordInvalidSigningCredential("no claim signer certificates",
+                                          status_set);
+  }
+  auto certs = crypto_read_handler_.ParseCertificatesDer(
+      StringViewVector(*certificate_chain));
+  if (!certs.ok()) {
+    return RecordInvalidSigningCredential(
+        absl::StrCat("malformed claim signer certificates: ",
+                     certs.status().message()),
+        status_set);
+  }
+
+  // STEP 1: Verify the timestamp.
+  // Obtain and verify the oldest verified timestamp from sig_tst2 or sig_tst
+  // in the unprotected header.
+  std::optional<VersionedVerifiedTimestamp> oldest_trusted_timestamp =
+      VerifyAndGetOldestTrustedTimestamp(
+          crypto_read_handler_, cose_sign1.unprotected_header, status_set);
+  // Check that the timestamp matches the signature.
+  bool check_message_imprint = true;
+  if (oldest_trusted_timestamp.has_value()) {
+    if (check_message_imprint &&
+        !MessageImprintMatch(external_payload, hash_checker_factory_,
+                             status_set, cose_sign1.protected_header,
+                             cose_sign1.signature_bstr(), cose_sign1.signature,
+                             *oldest_trusted_timestamp)) {
+      oldest_trusted_timestamp = std::nullopt;
+    } else {
+      RecordStatus(status_set, SuccessStatusCode::kTimestampValidated,
+                   {.url = kClaimSignatureLabel});
+      RecordStatus(status_set, SuccessStatusCode::kTimestampTrusted,
+                   {.url = kClaimSignatureLabel});
+    }
+  }
+
+  // STEP 2: Verify the signer's certificate chain.
+  absl::Time content_time =
+      oldest_trusted_timestamp.has_value()
+          ? oldest_trusted_timestamp->timestamp.asserted_time()
+          : clock_.TimeNow();
+  absl::StatusOr<std::vector<std::string>> trust_chain =
+      (*certs)->VerifyClaimSignerTrust(content_time);
+  if (!trust_chain.ok()) {
+    switch (trust_chain.status().code()) {
+      case absl::StatusCode::kUnauthenticated:
+        RecordStatus(status_set, FailureStatusCode::kSigningCredentialUntrusted,
+                     {.url = kClaimSignatureLabel,
+                      .explanation = trust_chain.status().message()});
+        return std::nullopt;
+      case absl::StatusCode::kOutOfRange:
+        RecordStatus(status_set,
+                     FailureStatusCode::kClaimSignatureOutsideValidity,
+                     {.url = kClaimSignatureLabel,
+                      .explanation = trust_chain.status().message()});
+        return std::nullopt;
+      case absl::StatusCode::kUnavailable:
+        // Trust list unavailable?
+        RecordStatus(status_set, FailureStatusCode::kGoogleInternalError,
+                     {.url = kClaimSignatureLabel,
+                      .explanation = trust_chain.status().message()});
+        return std::nullopt;
+      default:
+        return RecordInvalidSigningCredential(trust_chain.status().message(),
+                                              status_set);
+    }
+  }
+
+  // STEP 3: Verify the revocation information of the signer's cert chain.
+  // We first check the certificate revocation list, and then the stapled OCSP
+  // response as described in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_credential_revocation_information.
+  // Online OCSP checks are not supported yet.
+  if (!options_.check_ocsp_responses) {
+    RecordSkippedOcspCheck(status_set);
+  } else if (!oldest_trusted_timestamp.has_value()) {
+    // Trusted timestamp is required for verifying stapled OCSP responses
+    // (https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ocsp_stapled).
+    RecordSkippedOcspCheck(status_set);
+  } else {
+    OcspVerifier ocsp_verifier(&crypto_read_handler_);
+    if (!ocsp_verifier.VerifyOcspResponses(
+            cose_sign1.unprotected_header.ocsp_responses, *trust_chain,
+            oldest_trusted_timestamp->timestamp.asserted_time(), status_set)) {
+      // OCSP response indicates that a certificate in the chain is revoked.
+      return std::nullopt;
+    }
+  }
+
+  RecordStatus(status_set, SuccessStatusCode::kSigningCredentialTrusted,
+               {.url = kClaimSignatureLabel});
+  RecordStatus(status_set, SuccessStatusCode::kClaimSignatureInsideValidity,
+               {.url = kClaimSignatureLabel});
+
+  // STEP 4: Verify the signature.
+  if (options_.verify_signature) {
+    auto signing_algorithm = protected_headers->alg;
+    if (absl::Status status = (*certs)->VerifySignature(
+            cose_sign1.signature,
+            ConstructSigStructure(cose_sign1.protected_header, external_payload,
+                                  "Signature1"),
+            signing_algorithm);
+        !status.ok()) {
+      RecordStatus(status_set, FailureStatusCode::kClaimSignatureMismatch,
+                   {.url = kClaimSignatureLabel,
+                    .explanation = absl::Substitute(
+                        "claim signature validation failed; alg=$0; status=$1",
+                        signing_algorithm, status.ToString())});
+      return std::nullopt;
+    }
+    RecordStatus(status_set, SuccessStatusCode::kClaimSignatureValidated,
+                 {.url = kClaimSignatureLabel});
+  }
+
+  // Finally, construct and return the SignatureInfo.
+  SignatureInfo signature_info;
+  const ParsedCertificates& parsed_certs = **certs;
+  if (oldest_trusted_timestamp.has_value()) {
+    absl::StatusOr<google::protobuf::Timestamp> time_proto =
+        EncodeGoogleApiProto(
+            oldest_trusted_timestamp->timestamp.asserted_time());
+    if (time_proto.ok()) {
+      *signature_info.mutable_timestamping_time() = *std::move(time_proto);
+    } else {
+      RecordStatus(status_set, InformationalStatusCode::kTimestampOutOfRange,
+                   {.url = kClaimSignatureLabel});
+    }
+  }
+  absl::StatusOr<std::string> subject = parsed_certs.GetSubject(0);
+  if (subject.ok()) {
+    signature_info.set_issuer(*subject);
+  }
+  absl::StatusOr<std::string> issuer = parsed_certs.GetIssuer(0);
+  if (issuer.ok()) {
+    signature_info.set_certificate_issuer(*issuer);
+  }
+  absl::StatusOr<std::string> assurance_level =
+      parsed_certs.GetAssuranceLevel(0);
+  if (assurance_level.ok()) {
+    if (*assurance_level == "1.3.6.1.4.1.62558.3.10") {
+      signature_info.set_assurance_level(ASSURANCE_LEVEL1);
+    } else if (*assurance_level == "1.3.6.1.4.1.62558.3.20") {
+      signature_info.set_assurance_level(ASSURANCE_LEVEL2);
+    }
+  }
+  absl::StatusOr<std::string> conforming_product_id =
+      parsed_certs.GetConformingProductId(0);
+  if (conforming_product_id.ok()) {
+    signature_info.set_conforming_product_id(*conforming_product_id);
+  }
+  return signature_info;
+};
+
+}  // namespace credentio
diff --git a/cose/cose_sign1.h b/cose/cose_sign1.h
new file mode 100644
index 0000000..3f1afc5
--- /dev/null
+++ b/cose/cose_sign1.h
@@ -0,0 +1,79 @@
+// 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_COSE_COSE_SIGN1_H_
+#define THIRD_PARTY_CREDENTIO_COSE_COSE_SIGN1_H_
+
+#include <memory>
+#include <optional>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/clock_interface.h"
+#include "cose/sig_structure.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/hash.h"
+#include "google/protobuf/timestamp.pb.h"
+#include "proto/signature_info.pb.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+
+// CoseSign1 verifier for C2PA use cases.
+//
+// This class does not support multiple signers. Do not use this for non-C2PA
+// use cases.
+class CoseSign1Verifier {
+ public:
+  struct Options {
+    // Whether to check OCSP responses.
+    bool check_ocsp_responses = true;
+    // Whether to verify the signature.
+    // NOTE: signer's certificate chain trust checks are always performed.
+    bool verify_signature = true;
+  };
+
+  // Creates a CoseSign1Verifier instance.
+  static std::unique_ptr<CoseSign1Verifier> Create(
+      const CryptoReadHandler* absl_nonnull crypto_read_handler,
+      const HashCheckerFactory* absl_nonnull hash_checker_factory,
+      absl::Clock* absl_nonnull clock, Options options);
+
+  // Validates a COSE_Sign1 structure and returns a SignatureInfo on success,
+  // or std::nullopt if the signature is invalid.
+  std::optional<SignatureInfo> Verify(
+      const CoseSign1TaggedStructure& cose_sign1,
+      absl::string_view external_payload,
+      ValidationStatusSet* status_set) const;
+
+ private:
+  CoseSign1Verifier(const CryptoReadHandler* absl_nonnull crypto_read_handler,
+                    const HashCheckerFactory* absl_nonnull hash_checker_factory,
+                    absl::Clock* absl_nonnull clock, Options options)
+      : crypto_read_handler_(*crypto_read_handler),
+        hash_checker_factory_(*hash_checker_factory),
+        clock_(*clock),
+        options_(std::move(options)) {}
+
+  const CryptoReadHandler& crypto_read_handler_;
+  const HashCheckerFactory& hash_checker_factory_;
+  absl::Clock& clock_;
+  Options options_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_COSE_COSE_SIGN1_H_
diff --git a/cose/ocsp_verifier.cc b/cose/ocsp_verifier.cc
new file mode 100644
index 0000000..73a45b4
--- /dev/null
+++ b/cose/ocsp_verifier.cc
@@ -0,0 +1,106 @@
+// 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 "cose/ocsp_verifier.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "cose/validation_status_util.h"
+#include "crypto/crypto_read_handler.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+
+void RecordSkippedOcspCheck(ValidationStatusSet* status_set) {
+  RecordStatus(status_set,
+               InformationalStatusCode::kSigningCredentialOcspSkipped,
+               {.url = kClaimSignatureLabel});
+}
+
+bool OcspVerifier::VerifyOcspResponses(
+    absl::Span<const std::string> ocsp_responses,
+    absl::Span<const std::string> trust_chain, absl::Time asserted_time,
+    ValidationStatusSet* status_set) const {
+  bool has_good_signing_cert = false;
+  // The manifest has an attested time provided by a valid signed
+  // time-stamp and `rVals` header contains at least one OCSP response.
+  for (int i = 0; i < ocsp_responses.size(); ++i) {
+    absl::string_view ocsp_value = ocsp_responses[i];
+    for (int j = 1; j < trust_chain.size(); ++j) {
+      absl::string_view certificate = trust_chain[j - 1];
+      // The issuer certificate is the next certificate in the chain.
+      absl::string_view issuer_certificate = trust_chain[j];
+      auto revocation_status = crypto_read_handler_->VerifyOcspResponse(
+          ocsp_value, certificate, issuer_certificate, asserted_time);
+      if (revocation_status.ok()) {
+        if (*revocation_status == OCSPRevocationStatus::kGood) {
+          // According to the C2PA spec, if at least one OCSP response is
+          // `good`, then the credential is not revoked.
+          if (j == 1) {
+            has_good_signing_cert = true;
+          }
+          break;
+        } else if (*revocation_status == OCSPRevocationStatus::kRevoked) {
+          // According to the C2PA spec, if at least one OCSP response is
+          // `revoked`, then the credential is revoked.
+          if (j == 1) {
+            RecordStatus(status_set,
+                         FailureStatusCode::kSigningCredentialOcspRevoked,
+                         {.url = kClaimSignatureLabel});
+          } else {
+            RecordStatus(status_set,
+                         FailureStatusCode::kSigningCredentialUntrusted,
+                         {.url = kClaimSignatureLabel});
+          }
+          return false;
+        }
+      } else {
+        if (revocation_status.status().code() ==
+            absl::StatusCode::kOutOfRange) {
+          // OCSP response is produced at a time outside of the
+          // corresponding certificate's validity period or the timestamp
+          // time (i.e., the verify time) falls outside of the validity
+          // window of the stapled OCSP response.
+          RecordStatus(
+              status_set,
+              InformationalStatusCode::kSigningCredentialOcspOutsideValidity,
+              {.url = kClaimSignatureLabel});
+        }
+      }
+    }
+  }
+  if (has_good_signing_cert) {
+    RecordStatus(status_set,
+                 SuccessStatusCode::kSigningCredentialOcspNotRevoked,
+                 {.url = kClaimSignatureLabel});
+  } else {
+    // None of the OCSP responses satisfied the conditions in
+    // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ocsp_stapled,
+    // or no OCSP responses were provided. The validator chooses not to perform
+    // online revocation checks so we record a `signingCredential.ocsp.skipped`
+    // informational code per
+    // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ocsp_online.
+    RecordSkippedOcspCheck(status_set);
+  }
+  return true;
+}
+
+}  // namespace credentio
diff --git a/cose/ocsp_verifier.h b/cose/ocsp_verifier.h
new file mode 100644
index 0000000..adc2906
--- /dev/null
+++ b/cose/ocsp_verifier.h
@@ -0,0 +1,57 @@
+// 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_COSE_OCSP_VERIFIER_H_
+#define THIRD_PARTY_CREDENTIO_COSE_OCSP_VERIFIER_H_
+
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/crypto_read_handler.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+
+// Validates the stapled OCSP responses for a certificate trust chain as
+// described in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_credential_revocation_information.
+class OcspVerifier {
+ public:
+  explicit OcspVerifier(
+      const CryptoReadHandler* absl_nonnull crypto_read_handler)
+      : crypto_read_handler_(crypto_read_handler) {}
+
+  // Returns true and records appropriate success or informational status codes
+  // if the credential status is good or unknown.
+  // Returns false and records `signingCredential.ocsp.revoked` if the leaf
+  // certificate is verified as revoked, or `signingCredential.untrusted` if an
+  // intermediate certificate is verified as revoked.
+  bool VerifyOcspResponses(absl::Span<const std::string> ocsp_responses,
+                           absl::Span<const std::string> trust_chain,
+                           absl::Time asserted_time,
+                           ValidationStatusSet* status_set) const;
+
+ private:
+  const CryptoReadHandler* absl_nonnull crypto_read_handler_;
+};
+
+// Records that the online OCSP check was skipped.
+void RecordSkippedOcspCheck(ValidationStatusSet* status_set);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_COSE_OCSP_VERIFIER_H_
diff --git a/cose/ocsp_verifier_test.cc b/cose/ocsp_verifier_test.cc
new file mode 100644
index 0000000..91e6c1a
--- /dev/null
+++ b/cose/ocsp_verifier_test.cc
@@ -0,0 +1,366 @@
+// 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 "cose/ocsp_verifier.h"
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/clock.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "constants/status_codes.h"
+#include "crypto/default/pem.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/validation_status.pb.h"
+#include "testing/pem.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+namespace {
+
+using ::testing::ElementsAre;
+using ::testing::Eq;
+using ::testing::IsEmpty;
+using ::testing::IsFalse;
+using ::testing::SizeIs;
+
+class MockCryptoReadHandler : public CryptoReadHandler {
+ public:
+  MOCK_METHOD(absl::StatusOr<std::unique_ptr<ParsedCertificates>>,
+              ParseCertificatesDer,
+              (absl::Span<const absl::string_view> certificates),
+              (const, override));
+  MOCK_METHOD(absl::StatusOr<VerifiedTimestamp>, VerifyTimestamp,
+              (absl::string_view cms), (const, override));
+  MOCK_METHOD(absl::StatusOr<OCSPRevocationStatus>, VerifyOcspResponse,
+              (absl::string_view ocsp_response_der,
+               absl::string_view certificate_der,
+               absl::string_view issuer_certificate_der,
+               absl::Time verify_time),
+              (const, override));
+  MOCK_METHOD(TrustEnvironment, trust_environment, (), (const, override));
+};
+
+// Matches validation status codes.
+auto C2paStatusIs(auto status_code) {
+  return ::testing::Property(&ValidationStatus::code,
+                             Eq(absl::StrCat(status_code)));
+}
+
+absl::StatusOr<absl::Time> ParseTime(absl::string_view time_str) {
+  absl::Time time;
+  std::string err;
+  if (!absl::ParseTime(absl::RFC3339_full, time_str, &time, &err)) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Failed to parse time: ", err));
+  }
+  return time;
+}
+
+TEST(OcspVerifierTest, RecordSkippedOcspCheck) {
+  ValidationStatusSet status_set;
+  RecordSkippedOcspCheck(&status_set);
+
+  EXPECT_THAT(status_set.failures(), IsEmpty());
+  EXPECT_THAT(status_set.successes(), IsEmpty());
+  EXPECT_THAT(status_set.informationals(),
+              ElementsAre(C2paStatusIs(
+                  InformationalStatusCode::kSigningCredentialOcspSkipped)));
+}
+
+TEST(OcspVerifierTest, SkippedWhenResponsesEmpty) {
+  ValidationStatusSet status_set;
+  std::vector<std::string> ocsp_responses = {};
+  std::vector<std::string> trust_chain = {"fake_cert_1", "fake_cert_2"};
+  absl::Time asserted_time = absl::Now();
+
+  MockCryptoReadHandler mock_crypto_handler;
+  EXPECT_CALL(mock_crypto_handler, VerifyOcspResponse).Times(0);
+  OcspVerifier verifier(&mock_crypto_handler);
+
+  EXPECT_TRUE(verifier.VerifyOcspResponses(ocsp_responses, trust_chain,
+                                           asserted_time, &status_set));
+
+  EXPECT_THAT(status_set.failures(), IsEmpty());
+  EXPECT_THAT(status_set.successes(), IsEmpty());
+  EXPECT_THAT(status_set.informationals(),
+              ElementsAre(C2paStatusIs(
+                  InformationalStatusCode::kSigningCredentialOcspSkipped)));
+}
+
+TEST(OcspVerifierTest, SkippedWhenNoValidResponses) {
+  ValidationStatusSet status_set;
+  // Valid check config but fake inputs that won't match/decode successfully.
+  std::vector<std::string> ocsp_responses = {"invalid_ocsp_data"};
+  std::vector<std::string> trust_chain = {"invalid_cert_1", "invalid_cert_2"};
+  absl::Time asserted_time = absl::Now();
+
+  MockCryptoReadHandler mock_crypto_handler;
+  EXPECT_CALL(mock_crypto_handler, VerifyOcspResponse)
+      .WillOnce(::testing::Return(absl::InvalidArgumentError("invalid ocsp")));
+  OcspVerifier verifier(&mock_crypto_handler);
+
+  EXPECT_TRUE(verifier.VerifyOcspResponses(ocsp_responses, trust_chain,
+                                           asserted_time, &status_set));
+
+  // Since CheckOCSP fails to identify revocation status, it will be neither
+  // good nor revoked, hence recorded as skipped.
+  EXPECT_THAT(status_set.failures(), IsEmpty());
+  EXPECT_THAT(status_set.successes(), IsEmpty());
+  EXPECT_THAT(status_set.informationals(),
+              ElementsAre(C2paStatusIs(
+                  InformationalStatusCode::kSigningCredentialOcspSkipped)));
+}
+
+// certs/claim_signer_ocsp_2010_2024.pem
+static constexpr absl::string_view kSubjectCertPem =
+    R"(-----BEGIN CERTIFICATE-----
+MIIBVjCB/qADAgECAhEAqY+Ga0uKJc71iAGCQ2xmSTAKBggqhkjOPQQDAjAUMRIw
+EAYDVQQDEwlSb290IENBIDEwHhcNMTAwMTAxMDAwMDAwWhcNMjQxMjMxMjM1OTU5
+WjAcMRowGAYDVQQDExFDbGFpbSBTaWduZXIgT0NTUDBZMBMGByqGSM49AgEGCCqG
+SM49AwEHA0IABIekbtQdrGd0nLIoGkyxOxUHVDrdf4kCsoG1oy8hKUp0JX1nN6I5
+Uki0Rdnjjp0lkhRC7pS0ltq3RzYF2QLH9j2jKTAnMA4GA1UdDwEB/wQEAwIHgDAV
+BgNVHSUEDjAMBgorBgEEAYPoXgIBMAoGCCqGSM49BAMCA0cAMEQCIEoOXctlLyTC
+9ICtWt37D3UoLbtmGdPH/QKLuN5cEKmRAiABCe2qUdMzcrwR5rHJd2xQ8l4JZgv9
+t7/qA0tV/kHm6g==
+-----END CERTIFICATE-----
+)";
+
+// certs/root_ca1_cert.pem
+static constexpr absl::string_view kIssuerCertPem =
+    R"(-----BEGIN CERTIFICATE-----
+MIIBaDCCAQ6gAwIBAgIQQjiBDyR9IFSiOk3YI9Vz6jAKBggqhkjOPQQDAjAUMRIw
+EAYDVQQDEwlSb290IENBIDEwHhcNMDAwMTAxMDAwMDAwWhcNMjAwMTAxMDAwMDAw
+WjAUMRIwEAYDVQQDEwlSb290IENBIDEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC
+AATGlEZ2h/3CeOl6anwJPvk9rY0SbQAUnM7ogiSCh2Xh8A4MA/ZFvFxLokfUS+Le
+Rew96qN9o6jC7EXGs5Dlcql2o0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/
+BAUwAwEB/zAdBgNVHQ4EFgQUoD7enXLkSPQ4NFbWMsyPJw8CkREwCgYIKoZIzj0E
+AwIDSAAwRQIgZHyZ6carq1CPrvQA87zDsfqVe1Hcy7/Hd2Pz8fTbn/YCIQC68wrO
+LL8RURusZlo0c7KhG/TBTG368XayFcJbTq5piA==
+-----END CERTIFICATE-----
+)";
+
+// ocsp/good_2012.pem
+static constexpr absl::string_view kGoodOcspResponsePem =
+    R"(-----BEGIN OCSP RESPONSE-----
+MIICeAoBAKCCAnEwggJtBgkrBgEFBQcwAQEEggJeMIICWjCBpKEbMBkxFzAVBgNV
+BAMTDk9DU1AgUmVzcG9uZGVyGA8yMDEyMDEwMTAwMDAwMFowdDByMEowCQYFKw4D
+AhoFAAQUH/QKbQfkANfLUhHO1/Ux3C+InIMEFLEaFwXj/kcm0icZbAOdlyD4YMUx
+AhEAqY+Ga0uKJc71iAGCQ2xmSYAAGA8yMDEyMDEwMTAwMDAwMFqgERgPMjAxMjEy
+MzEyMzU5NTlaMAoGCCqGSM49BAMCA0cAMEQCIFBWX+xMGwGlswHlzGoxUqNe4MQS
+6M6rsH3WVhB6V1XqAiAH7ENBzOuqkiwuyn/Ll22OehLDOwhGkcPj4ngBH981X6CC
+AVowggFWMIIBUjCB+aADAgECAhEAylWc524QSfWaV4Y1C8VtVTAKBggqhkjOPQQD
+AjAUMRIwEAYDVQQDEwlSb290IENBIDEwHhcNMTAwMTAxMDAwMDAwWhcNMjQxMjMx
+MjM1OTU5WjAZMRcwFQYDVQQDEw5PQ1NQIFJlc3BvbmRlcjBZMBMGByqGSM49AgEG
+CCqGSM49AwEHA0IABBxOD6IfWwY2vJ/dQfo3iquqL7jqrliO6KquLxGJYOuhmGiX
+mgKhnXHv9fAF3xnb09BHZMqVvkW/Vjqx3VvWRjajJzAlMA4GA1UdDwEB/wQEAwIH
+gDATBgNVHSUEDDAKBggrBgEFBQcDCTAKBggqhkjOPQQDAgNIADBFAiAk4CANeeK7
+R5RQ+lgZkgiegSUaFu4pxHJzQ8yK2XnsqQIhAMevOsBvD9R6o5AmZJgn/dDsNwGQ
+FuM38EKpIEjm+lH0
+-----END OCSP RESPONSE-----
+)";
+
+// ocsp/revoked_2012.pem
+static constexpr absl::string_view kRevokedOcspResponsePem =
+    R"(-----BEGIN OCSP RESPONSE-----
+MIICjQoBAKCCAoYwggKCBgkrBgEFBQcwAQEEggJzMIICbzCBt6EbMBkxFzAVBgNV
+BAMTDk9DU1AgUmVzcG9uZGVyGA8yMDEyMDEwMTAwMDAwMFowgYYwgYMwSjAJBgUr
+DgMCGgUABBQf9AptB+QA18tSEc7X9THcL4icgwQUsRoXBeP+RybSJxlsA52XIPhg
+xTECEQCpj4ZrS4olzvWIAYJDbGZJoREYDzIwMTIwMTAxMDAwMDAwWhgPMjAxMjAx
+MDEwMDAwMDBaoBEYDzIwMTIxMjMxMjM1OTU5WjAKBggqhkjOPQQDAgNJADBGAiEA
+1N0hbCk0zfZUIWYqwwPmymq4l6/6379wQClNBvr+bWcCIQC8LPYY2wwrPGUa/LRu
+hNmuv3wgUpapuqtrpRP9ool4S6CCAVowggFWMIIBUjCB+aADAgECAhEAylWc524Q
+SfWaV4Y1C8VtVTAKBggqhkjOPQQDAjAUMRIwEAYDVQQDEwlSb290IENBIDEwHhcN
+MTAwMTAxMDAwMDAwWhcNMjQxMjMxMjM1OTU5WjAZMRcwFQYDVQQDEw5PQ1NQIFJl
+c3BvbmRlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBxOD6IfWwY2vJ/dQfo3
+iquqL7jqrliO6KquLxGJYOuhmGiXmgKhnXHv9fAF3xnb09BHZMqVvkW/Vjqx3VvW
+RjajJzAlMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDCTAKBggq
+hkjOPQQDAgNIADBFAiAk4CANeeK7R5RQ+lgZkgiegSUaFu4pxHJzQ8yK2XnsqQIh
+AMevOsBvD9R6o5AmZJgn/dDsNwGQFuM38EKpIEjm+lH0
+-----END OCSP RESPONSE-----
+)";
+
+TEST(OcspVerifierTest, MatchingGoodResponse) {
+  auto subject_certs = LoadCertsFromPem(kSubjectCertPem);
+  ABSL_ASSERT_OK(subject_certs);
+  ASSERT_THAT(*subject_certs, SizeIs(1));
+
+  auto issuer_certs = LoadCertsFromPem(kIssuerCertPem);
+  ABSL_ASSERT_OK(issuer_certs);
+  ASSERT_THAT(*issuer_certs, SizeIs(1));
+
+  auto ocsp_response = LoadOcspResponseFromPem(kGoodOcspResponsePem);
+  ABSL_ASSERT_OK(ocsp_response);
+
+  ValidationStatusSet status_set;
+  std::vector<std::string> ocsp_responses = {*ocsp_response};
+  std::vector<std::string> trust_chain = {(*subject_certs)[0],
+                                          (*issuer_certs)[0]};
+
+  auto asserted_time = ParseTime("2012-06-01T00:00:00Z");
+  ABSL_ASSERT_OK(asserted_time);
+
+  MockCryptoReadHandler mock_crypto_handler;
+  EXPECT_CALL(mock_crypto_handler,
+              VerifyOcspResponse(*ocsp_response, (*subject_certs)[0],
+                                 (*issuer_certs)[0], *asserted_time))
+      .WillOnce(::testing::Return(OCSPRevocationStatus::kGood));
+  OcspVerifier verifier(&mock_crypto_handler);
+
+  EXPECT_TRUE(verifier.VerifyOcspResponses(ocsp_responses, trust_chain,
+                                           *asserted_time, &status_set));
+
+  EXPECT_THAT(status_set.failures(), IsEmpty());
+  EXPECT_THAT(status_set.informationals(), IsEmpty());
+  EXPECT_THAT(status_set.successes(),
+              ElementsAre(C2paStatusIs(
+                  SuccessStatusCode::kSigningCredentialOcspNotRevoked)));
+}
+
+TEST(OcspVerifierTest, MatchingRevokedResponse) {
+  auto subject_certs = LoadCertsFromPem(kSubjectCertPem);
+  ABSL_ASSERT_OK(subject_certs);
+  ASSERT_THAT(*subject_certs, SizeIs(1));
+
+  auto issuer_certs = LoadCertsFromPem(kIssuerCertPem);
+  ABSL_ASSERT_OK(issuer_certs);
+  ASSERT_THAT(*issuer_certs, SizeIs(1));
+
+  auto ocsp_response = LoadOcspResponseFromPem(kRevokedOcspResponsePem);
+  ABSL_ASSERT_OK(ocsp_response);
+
+  ValidationStatusSet status_set;
+  std::vector<std::string> ocsp_responses = {*ocsp_response};
+  std::vector<std::string> trust_chain = {(*subject_certs)[0],
+                                          (*issuer_certs)[0]};
+
+  auto asserted_time = ParseTime("2012-06-01T00:00:00Z");
+  ABSL_ASSERT_OK(asserted_time);
+
+  MockCryptoReadHandler mock_crypto_handler;
+  EXPECT_CALL(mock_crypto_handler,
+              VerifyOcspResponse(*ocsp_response, (*subject_certs)[0],
+                                 (*issuer_certs)[0], *asserted_time))
+      .WillOnce(::testing::Return(OCSPRevocationStatus::kRevoked));
+  OcspVerifier verifier(&mock_crypto_handler);
+
+  EXPECT_THAT(verifier.VerifyOcspResponses(ocsp_responses, trust_chain,
+                                           *asserted_time, &status_set),
+              IsFalse());
+
+  EXPECT_THAT(status_set.failures(),
+              ElementsAre(C2paStatusIs(
+                  FailureStatusCode::kSigningCredentialOcspRevoked)));
+  EXPECT_THAT(status_set.informationals(), IsEmpty());
+  EXPECT_THAT(status_set.successes(), IsEmpty());
+}
+
+TEST(OcspVerifierTest, MatchingRevokedResponseOnIntermediateCert) {
+  auto subject_certs = LoadCertsFromPem(kSubjectCertPem);
+  ABSL_ASSERT_OK(subject_certs);
+  ASSERT_THAT(*subject_certs, SizeIs(1));
+
+  auto issuer_certs = LoadCertsFromPem(kIssuerCertPem);
+  ABSL_ASSERT_OK(issuer_certs);
+  ASSERT_THAT(*issuer_certs, SizeIs(1));
+
+  auto ocsp_response = LoadOcspResponseFromPem(kRevokedOcspResponsePem);
+  ABSL_ASSERT_OK(ocsp_response);
+
+  ValidationStatusSet status_set;
+  std::vector<std::string> ocsp_responses = {*ocsp_response};
+  // Insert a fake leaf cert so that subject_certs[0] is considered an
+  // intermediate cert.
+  std::vector<std::string> trust_chain = {"fake_leaf_cert", (*subject_certs)[0],
+                                          (*issuer_certs)[0]};
+
+  auto asserted_time = ParseTime("2012-06-01T00:00:00Z");
+  ABSL_ASSERT_OK(asserted_time);
+
+  MockCryptoReadHandler mock_crypto_handler;
+  EXPECT_CALL(mock_crypto_handler,
+              VerifyOcspResponse(*ocsp_response, "fake_leaf_cert",
+                                 (*subject_certs)[0], *asserted_time))
+      .WillOnce(::testing::Return(absl::InvalidArgumentError("no match")));
+  EXPECT_CALL(mock_crypto_handler,
+              VerifyOcspResponse(*ocsp_response, (*subject_certs)[0],
+                                 (*issuer_certs)[0], *asserted_time))
+      .WillOnce(::testing::Return(OCSPRevocationStatus::kRevoked));
+  OcspVerifier verifier(&mock_crypto_handler);
+
+  EXPECT_THAT(verifier.VerifyOcspResponses(ocsp_responses, trust_chain,
+                                           *asserted_time, &status_set),
+              IsFalse());
+
+  EXPECT_THAT(status_set.failures(),
+              ElementsAre(C2paStatusIs(
+                  FailureStatusCode::kSigningCredentialUntrusted)));
+  EXPECT_THAT(status_set.informationals(), IsEmpty());
+  EXPECT_THAT(status_set.successes(), IsEmpty());
+}
+
+TEST(OcspVerifierTest, MatchingGoodResponseOnIntermediateCert) {
+  auto subject_certs = LoadCertsFromPem(kSubjectCertPem);
+  ABSL_ASSERT_OK(subject_certs);
+  ASSERT_THAT(*subject_certs, SizeIs(1));
+
+  auto issuer_certs = LoadCertsFromPem(kIssuerCertPem);
+  ABSL_ASSERT_OK(issuer_certs);
+  ASSERT_THAT(*issuer_certs, SizeIs(1));
+
+  auto ocsp_response = LoadOcspResponseFromPem(kGoodOcspResponsePem);
+  ABSL_ASSERT_OK(ocsp_response);
+
+  ValidationStatusSet status_set;
+  std::vector<std::string> ocsp_responses = {*ocsp_response};
+  // Insert a fake leaf cert so that subject_certs[0] is considered an
+  // intermediate cert.
+  std::vector<std::string> trust_chain = {"fake_leaf_cert", (*subject_certs)[0],
+                                          (*issuer_certs)[0]};
+
+  auto asserted_time = ParseTime("2012-06-01T00:00:00Z");
+  ABSL_ASSERT_OK(asserted_time);
+
+  MockCryptoReadHandler mock_crypto_handler;
+  EXPECT_CALL(mock_crypto_handler,
+              VerifyOcspResponse(*ocsp_response, "fake_leaf_cert",
+                                 (*subject_certs)[0], *asserted_time))
+      .WillOnce(::testing::Return(absl::InvalidArgumentError("no match")));
+  EXPECT_CALL(mock_crypto_handler,
+              VerifyOcspResponse(*ocsp_response, (*subject_certs)[0],
+                                 (*issuer_certs)[0], *asserted_time))
+      .WillOnce(::testing::Return(OCSPRevocationStatus::kGood));
+  OcspVerifier verifier(&mock_crypto_handler);
+
+  EXPECT_TRUE(verifier.VerifyOcspResponses(ocsp_responses, trust_chain,
+                                           *asserted_time, &status_set));
+
+  EXPECT_THAT(status_set.failures(), IsEmpty());
+  EXPECT_THAT(status_set.informationals(),
+              ElementsAre(C2paStatusIs(
+                  InformationalStatusCode::kSigningCredentialOcspSkipped)));
+  EXPECT_THAT(status_set.successes(), IsEmpty());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/cose/sig_structure.cc b/cose/sig_structure.cc
new file mode 100644
index 0000000..055da02
--- /dev/null
+++ b/cose/sig_structure.cc
@@ -0,0 +1,353 @@
+// 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 "cose/sig_structure.h"
+
+#include <cstdint>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_log.h"
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "cbor/cbor.h"
+#include "cbor/parse.h"
+#include "cppbor/cppbor.h"
+#include "crypto/algorithms.h"
+
+namespace credentio {
+namespace {
+// 'alg' in https://datatracker.ietf.org/doc/html/rfc8152#section-3.1
+constexpr int kSigningAlgorithmKey = 1;
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_obtaining_the_time_stamp
+// for more details.
+constexpr absl::string_view kTimestampV2HeaderKey = "sigTst2";
+// DEPRECATED. DO NOT GENERATE. Contents of 'sigTst' CBOR map.
+constexpr absl::string_view kTimestampV1HeaderKey = "sigTst";
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_create_a_temporary_claim_and_signature
+// for more details about padding.
+constexpr absl::string_view kPadHeaderKey = "pad";
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_going_back_and_filling_in
+// about using `pad2`.
+constexpr absl::string_view kPad2HeaderKey = "pad2";
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#x509_certificates
+constexpr int kCertificateChainKey = 33;
+constexpr absl::string_view kCertificateChainDeprecatedKey = "x5chain";
+constexpr absl::string_view kOcspValsKey = "ocspVals";
+constexpr absl::string_view kRvalsKey = "rVals";
+
+// https://www.iana.org/assignments/cose/cose.xhtml#algorithms
+absl::StatusOr<int64_t> GetAlgorithm(SigningAlgorithm e) {
+  switch (e) {
+    case SigningAlgorithm::kEs256:
+      return -7;
+    case SigningAlgorithm::kEdDsa:
+      return -8;
+    case SigningAlgorithm::kEs384:
+      return -35;
+    case SigningAlgorithm::kEs512:
+      return -36;
+    case SigningAlgorithm::kPs256:
+      return -37;
+    case SigningAlgorithm::kPs384:
+      return -38;
+    case SigningAlgorithm::kPs512:
+      return -39;
+  }
+  return absl::InvalidArgumentError("Unsupported algorithm");
+}
+
+absl::StatusOr<SigningAlgorithm> GetSigningAlgorithm(cbor::MapView headers) {
+  auto alg = headers.GetInt64(kSigningAlgorithmKey);
+  if (!alg.ok()) {
+    return absl::Status(
+        alg.status().code(),
+        absl::StrCat("no COSE algorithm specified in protected headers: ",
+                     alg.status().message()));
+  }
+  // https://www.iana.org/assignments/cose/cose.xhtml#algorithms
+  ABSL_DVLOG(1) << ".cose_alg = " << *alg << ",";
+  switch (*alg) {
+    case -7:
+      return SigningAlgorithm::kEs256;
+    case -35:
+      return SigningAlgorithm::kEs384;
+    case -36:
+      return SigningAlgorithm::kEs512;
+    case -37:
+      return SigningAlgorithm::kPs256;
+    case -38:
+      return SigningAlgorithm::kPs384;
+    case -39:
+      return SigningAlgorithm::kPs512;
+    case -8:
+      return SigningAlgorithm::kEdDsa;
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("unsupported COSE algorithm: ", *alg));
+}
+
+absl::StatusOr<std::vector<std::string>> GetCertificateChain(
+    cbor::MapView headers) {
+  // Check if there is a single certificate in the chain. In this case, label 33
+  // of the header map (or "x5chain" key) is a bstr.
+  // Claim generators should only use 33 (integer) key but the validators should
+  // both accept 33 and "x5chain" keys. If both 33 (integer) and "x5chain"
+  // (string) keys are present, the label 33 should be used.
+  auto certificate_chain = headers.GetByteString(kCertificateChainKey);
+  if (!certificate_chain.ok()) {
+    certificate_chain = headers.GetByteString(kCertificateChainDeprecatedKey);
+  }
+  if (certificate_chain.ok()) {
+    return std::vector<std::string>({std::string(*certificate_chain)});
+  }
+  // Check if there is a list of certificates in the chain. In this case, label
+  // 33 of the header map (or "x5chain" key) is an array of bstrs.
+  auto certificate_chain_array = headers.GetArray(kCertificateChainKey);
+  if (!certificate_chain_array.ok()) {
+    certificate_chain_array = headers.GetArray(kCertificateChainDeprecatedKey);
+  }
+  if (certificate_chain_array.ok()) {
+    std::vector<std::string> certificate_chain;
+    for (int i = 0; i < certificate_chain_array->size(); ++i) {
+      auto certificate_chain_element =
+          certificate_chain_array->GetByteString(i);
+      if (!certificate_chain_element.ok()) {
+        return absl::InvalidArgumentError(
+            absl::StrCat("Failed to parse the certificate chain element: ",
+                         certificate_chain_element.status()));
+      }
+      ABSL_DVLOG(1) << ".certs_b64[" << i << "] = R\"("
+                    << absl::Base64Escape(*certificate_chain_element) << ")\",";
+      certificate_chain.push_back(std::string(*certificate_chain_element));
+    }
+    return certificate_chain;
+  }
+  return std::vector<std::string>();
+}
+
+std::vector<std::string> DecodeOcspResponses(cbor::MapView map) {
+  auto rvals = map.GetOptionalMap(kRvalsKey);
+  if (!rvals.has_value()) {
+    return {};
+  }
+  auto ocsp_responses = rvals->GetArray(kOcspValsKey);
+  if (!ocsp_responses.ok()) {
+    return {};
+  }
+  std::vector<std::string> v;
+  v.reserve(ocsp_responses->size());
+  for (int i = 0; i < ocsp_responses->size(); ++i) {
+    auto ocsp_response = ocsp_responses->GetByteString(i);
+    if (ocsp_response.ok()) {
+      v.push_back(std::string(*ocsp_response));
+    }
+  }
+  return v;
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> EncodeProtectedHeader(
+    const ProtectedHeader& protected_header) {
+  cppbor::Map m;
+  ABSL_ASSIGN_OR_RETURN(auto alg, GetAlgorithm(protected_header.alg));
+  m.add(cppbor::Uint(kSigningAlgorithmKey), cppbor::Nint(alg));
+  if (protected_header.certificate_chain.size() == 1) {
+    m.add(cppbor::Uint(kCertificateChainKey),
+          cppbor::Bstr(protected_header.certificate_chain[0]));
+  } else {
+    cppbor::Array chain;
+    for (const auto& cert : protected_header.certificate_chain) {
+      chain.add(cppbor::Bstr(cert));
+    }
+    m.add(cppbor::Uint(kCertificateChainKey), std::move(chain));
+  }
+  return m.toString();
+}
+
+absl::StatusOr<ProtectedHeader> DecodeProtectedHeader(absl::string_view cbor) {
+  ProtectedHeader protected_header;
+  ABSL_ASSIGN_OR_RETURN(auto res, cbor::Parse(cbor));
+  ABSL_ASSIGN_OR_RETURN(auto map, res->AsMap());
+  ABSL_ASSIGN_OR_RETURN(auto alg, GetSigningAlgorithm(map));
+  protected_header.alg = alg;
+
+  ABSL_ASSIGN_OR_RETURN(auto certificate_chain, GetCertificateChain(map));
+  protected_header.certificate_chain = std::move(certificate_chain);
+  return protected_header;
+}
+
+void EncodeTstToken(const TstToken& tst_token, cppbor::Map& m) {
+  m.add(cppbor::Tstr("val"), cppbor::Bstr(tst_token.val));
+}
+
+absl::StatusOr<TstToken> DecodeTstToken(cbor::MapView map) {
+  ABSL_ASSIGN_OR_RETURN(auto val, map.GetByteString("val"));
+  return TstToken{.val = std::string(val)};
+}
+
+void EncodeTstContainer(const TstContainer& tst_container, cppbor::Map& m) {
+  cppbor::Array tstTokens;
+  for (const auto& tstToken : tst_container.tst_tokens) {
+    cppbor::Map tstTokenMap;
+    EncodeTstToken(tstToken, tstTokenMap);
+    tstTokens.add(std::move(tstTokenMap));
+  }
+  m.add(cppbor::Tstr("tstTokens"), std::move(tstTokens));
+}
+
+absl::StatusOr<TstContainer> DecodeTstContainer(cbor::MapView map) {
+  ABSL_ASSIGN_OR_RETURN(auto tst_tokens, map.GetArray("tstTokens"));
+  TstContainer tst_container;
+  for (int i = 0; i < tst_tokens.size(); ++i) {
+    ABSL_ASSIGN_OR_RETURN(auto tst_token, tst_tokens.GetMap(i));
+    ABSL_ASSIGN_OR_RETURN(auto tst_token_decoded, DecodeTstToken(tst_token));
+    tst_container.tst_tokens.push_back(std::move(tst_token_decoded));
+  }
+  return tst_container;
+}
+
+void EncodeUnprotectedHeader(const UnprotectedHeader& unprotected_header,
+                             cppbor::Map& m) {
+  if (unprotected_header.sig_tst2.has_value()) {
+    cppbor::Map tstContainer;
+    EncodeTstContainer(*unprotected_header.sig_tst2, tstContainer);
+    m.add(cppbor::Tstr(kTimestampV2HeaderKey), std::move(tstContainer));
+  }
+  if (!unprotected_header.ocsp_responses.empty()) {
+    cppbor::Array ocsp_responses;
+    for (const auto& ocsp_response : unprotected_header.ocsp_responses) {
+      ocsp_responses.add(cppbor::Bstr(ocsp_response));
+    }
+    cppbor::Map rvals;
+    rvals.add(cppbor::Tstr(kOcspValsKey), std::move(ocsp_responses));
+    m.add(cppbor::Tstr(kRvalsKey), std::move(rvals));
+  }
+  if (unprotected_header.pad.has_value()) {
+    m.add(cppbor::Tstr(kPadHeaderKey), cppbor::Bstr(*unprotected_header.pad));
+  }
+  if (unprotected_header.pad2.has_value()) {
+    m.add(cppbor::Tstr(kPad2HeaderKey), cppbor::Bstr(*unprotected_header.pad2));
+  }
+}
+
+absl::StatusOr<UnprotectedHeader> DecodeUnprotectedHeader(cbor::MapView map) {
+  UnprotectedHeader unprotected_header;
+  auto sig_tst2 = map.GetOptionalMap(kTimestampV2HeaderKey);
+  if (sig_tst2.has_value()) {
+    ABSL_ASSIGN_OR_RETURN(unprotected_header.sig_tst2,
+                          DecodeTstContainer(*sig_tst2));
+  }
+  auto sig_tst = map.GetOptionalMap(kTimestampV1HeaderKey);
+  if (sig_tst.has_value()) {
+    ABSL_ASSIGN_OR_RETURN(unprotected_header.sig_tst,
+                          DecodeTstContainer(*sig_tst));
+  }
+  unprotected_header.ocsp_responses = DecodeOcspResponses(map);
+  auto pad = map.GetOptionalByteString(kPadHeaderKey);
+  if (pad.has_value()) {
+    unprotected_header.pad = std::string(*pad);
+  }
+  auto pad2 = map.GetOptionalByteString(kPad2HeaderKey);
+  if (pad2.has_value()) {
+    unprotected_header.pad2 = std::string(*pad2);
+  }
+  auto certificate_chain = GetCertificateChain(map);
+  if (certificate_chain.ok()) {
+    unprotected_header.certificate_chain = *std::move(certificate_chain);
+  }
+  return unprotected_header;
+}
+
+std::string EncodeSig1Structure(const Sig1Structure& sig_structure) {
+  cppbor::Array s;
+  s.add(cppbor::Tstr(sig_structure.context));
+  s.add(cppbor::Bstr(sig_structure.body_protected));
+  s.add(cppbor::Bstr(sig_structure.external_aad));
+  s.add(cppbor::Bstr(sig_structure.payload));
+  return s.toString();
+}
+
+std::string EncodeCoseSign1TaggedStructure(const CoseSign1TaggedStructure& s) {
+  cppbor::Array array;
+  // Add protected_header.
+  array.add(cppbor::Bstr(s.protected_header));
+  // Add unprotected_header.
+  cppbor::Map unprotected_header_map;
+  EncodeUnprotectedHeader(s.unprotected_header, unprotected_header_map);
+  array.add(std::move(unprotected_header_map));
+  // Add payload.
+  if (s.payload.has_value()) {
+    array.add(cppbor::Bstr(*s.payload));
+  } else {
+    array.add(cppbor::Null());
+  }
+  // Add signature.
+  array.add(cppbor::Bstr(s.signature));
+  // tag 18 see https://datatracker.ietf.org/doc/html/rfc8152#section-4.2
+  cppbor::SemanticTag tagged_cbor(18, std::move(array));
+  return tagged_cbor.toString();
+}
+
+absl::StatusOr<CoseSign1TaggedStructure> DecodeCoseSign1TaggedStructure(
+    absl::string_view cbor) {
+  // COSE_Sign1 structure is a CBOR array of size 4.
+  // CDDL schema for the case of a single signer is:
+  //
+  // COSE_Sign1 = [
+  //     protected: bstr,
+  //     unprotected: map,
+  //     payload: bstr / nil,
+  //     signature: bstr
+  // ]
+  //
+  // See https://datatracker.ietf.org/doc/html/rfc9052 for more
+  // details.
+  ABSL_ASSIGN_OR_RETURN(auto res, cbor::Parse(cbor));
+  ABSL_ASSIGN_OR_RETURN(auto array, res->AsArray());
+  CoseSign1TaggedStructure s;
+  ABSL_ASSIGN_OR_RETURN(s.protected_header, array.GetByteString(0));
+  ABSL_ASSIGN_OR_RETURN(auto unprotect_header_cbor, array.GetMap(1));
+  ABSL_ASSIGN_OR_RETURN(s.unprotected_header,
+                        DecodeUnprotectedHeader(unprotect_header_cbor));
+  ABSL_ASSIGN_OR_RETURN(auto is_payload_null, array.IsNull(2));
+  if (!is_payload_null) {
+    ABSL_ASSIGN_OR_RETURN(auto payload, array.GetByteString(2));
+    s.payload.emplace(payload);
+  }
+
+  // In COSE standard, there can also be an array of signatures instead of a
+  // single signature, if there are multiple signers. However, in C2PA we will
+  // always have a single signer.
+  ABSL_ASSIGN_OR_RETURN(auto signature, array.GetByteString(3));
+  s.signature = std::string(signature);
+  return s;
+}
+
+std::string CoseSign1TaggedStructure::signature_bstr() const {
+  return cppbor::Bstr(signature).toString();
+}
+
+}  // namespace credentio
diff --git a/cose/sig_structure.h b/cose/sig_structure.h
new file mode 100644
index 0000000..960ad43
--- /dev/null
+++ b/cose/sig_structure.h
@@ -0,0 +1,137 @@
+// 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_COSE_SIG_STRUCTURE_H_
+#define THIRD_PARTY_CREDENTIO_COSE_SIG_STRUCTURE_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+
+namespace credentio {
+
+struct ProtectedHeader {
+  SigningAlgorithm alg;
+  std::vector<std::string> certificate_chain;
+
+  bool operator==(const ProtectedHeader& other) const {
+    return alg == other.alg && certificate_chain == other.certificate_chain;
+  }
+};
+
+// Encodes ProtectedHeader as a serialized CBOR map.
+absl::StatusOr<std::string> EncodeProtectedHeader(
+    const ProtectedHeader& protected_header);
+
+// Decodes ProtectedHeader serialized as a CBOR map.
+absl::StatusOr<ProtectedHeader> DecodeProtectedHeader(absl::string_view cbor);
+
+// Sig_structure with sign_protected field omitted.
+// Sig_structure = [
+//   context : "Signature" / "Signature1" / "CounterSignature",
+//   body_protected : empty_or_serialized_map,
+//   external_aad : bstr,
+//   payload : bstr
+// ]
+struct Sig1Structure {
+  std::string context;
+  std::string body_protected;
+  std::string external_aad;
+  std::string payload;
+
+  bool operator==(const Sig1Structure& other) const {
+    return context == other.context && body_protected == other.body_protected &&
+           external_aad == other.external_aad && payload == other.payload;
+  }
+};
+
+// Encodes Sig1Structure to a CBOR byte string.
+std::string EncodeSig1Structure(const Sig1Structure& sig_structure);
+
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_storing_the_time_stamp
+struct TstToken {
+  std::string val;  // bstr in the CBOR.
+
+  bool operator==(const TstToken& other) const { return val == other.val; }
+};
+
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_storing_the_time_stamp
+struct TstContainer {
+  std::vector<TstToken> tst_tokens;  // contents of 'tstTokens' CBOR array.
+
+  bool operator==(const TstContainer& other) const {
+    return tst_tokens == other.tst_tokens;
+  }
+};
+
+struct UnprotectedHeader {
+  // See
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_storing_the_time_stamp.
+  std::optional<TstContainer> sig_tst2;  // contents of 'sigTst2' CBOR map.
+  // DEPRECATED. DO NOT GENERATE. Contents of 'sigTst' CBOR map.
+  std::optional<TstContainer> sig_tst;
+  // DEPRECATED. DO NOT GENERATE. Contents of certificate chain, which should be
+  // in the protected header.
+  std::vector<std::string> certificate_chain;
+  // See
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_certificate_revocation.
+  std::vector<std::string> ocsp_responses;  // contents of `ocspVals` array.
+  // See
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_going_back_and_filling_in
+  // about padding.
+  std::optional<std::string> pad;   // contents of 'pad' CBOR bstr.
+  std::optional<std::string> pad2;  // contents of 'pad2' CBOR bstr.
+
+  bool operator==(const UnprotectedHeader& other) const {
+    return sig_tst2 == other.sig_tst2 && sig_tst == other.sig_tst &&
+           certificate_chain == other.certificate_chain &&
+           ocsp_responses == other.ocsp_responses && pad == other.pad &&
+           pad2 == other.pad2;
+  }
+};
+
+// COSE_Sign1 = [
+//     protected: bstr,
+//     unprotected: map,
+//     payload: bstr / nil,
+//     signature: bstr
+// ]
+struct CoseSign1TaggedStructure {
+  std::string protected_header;
+  UnprotectedHeader unprotected_header;
+  std::optional<std::string> payload = std::nullopt;
+  std::string signature;
+  // Returns the signature as a CBOR-encoded byte string.
+  std::string signature_bstr() const;
+
+  bool operator==(const CoseSign1TaggedStructure& other) const {
+    return protected_header == other.protected_header &&
+           unprotected_header == other.unprotected_header &&
+           payload == other.payload && signature == other.signature;
+  }
+};
+
+std::string EncodeCoseSign1TaggedStructure(const CoseSign1TaggedStructure& s);
+absl::StatusOr<CoseSign1TaggedStructure> DecodeCoseSign1TaggedStructure(
+    absl::string_view cbor);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_COSE_SIG_STRUCTURE_H_
diff --git a/cose/sig_structure_test.cc b/cose/sig_structure_test.cc
new file mode 100644
index 0000000..f9d87f0
--- /dev/null
+++ b/cose/sig_structure_test.cc
@@ -0,0 +1,340 @@
+// 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 "cose/sig_structure.h"
+
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#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 "absl/strings/substitute.h"
+#include "cbor/cbor.h"
+#include "cbor/parse.h"
+#include "crypto/algorithms.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "testing/cbor_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+
+absl::StatusOr<Sig1Structure> DecodeSig1Structure(absl::string_view cbor) {
+  ABSL_ASSIGN_OR_RETURN(auto res, cbor::Parse(cbor));
+  ABSL_ASSIGN_OR_RETURN(auto array, res->AsArray());
+  Sig1Structure s;
+
+  auto context = array.GetString(0);
+  if (!context.ok()) {
+    return absl::Status(
+        context.status().code(),
+        absl::StrCat("could not get `context`: ", context.status().message()));
+  }
+  s.context = std::string(*context);
+
+  auto body_protected = array.GetByteString(1);
+  if (!body_protected.ok()) {
+    return absl::Status(body_protected.status().code(),
+                        absl::StrCat("could not get `body_protected`: ",
+                                     body_protected.status().message()));
+  }
+  s.body_protected = std::string(*body_protected);
+
+  auto external_aad = array.GetByteString(2);
+  if (!external_aad.ok()) {
+    return absl::Status(external_aad.status().code(),
+                        absl::StrCat("could not get `external_aad`: ",
+                                     external_aad.status().message()));
+  }
+  s.external_aad = std::string(*external_aad);
+
+  auto payload = array.GetByteString(3);
+  if (!payload.ok()) {
+    return absl::Status(
+        payload.status().code(),
+        absl::StrCat("could not get `payload`: ", payload.status().message()));
+  }
+  s.payload = std::string(*payload);
+
+  return s;
+}
+
+std::string Base64Unescape(absl::string_view base64) {
+  std::string bytes;
+  CHECK(absl::Base64Unescape(base64, &bytes));
+  return bytes;
+}
+
+// Asserts the value is a byte string, and equals to the `hex` argument in hex
+// representation.
+MATCHER_P(IsBytesEqHexStr, hex, "") {
+  auto hex_arg = absl::BytesToHexString(arg);
+  *result_listener << absl::StrCat("Hex strings ", hex, " and ", hex_arg,
+                                   " are not equal");
+  return hex == hex_arg;
+}
+
+TEST(SigStructureTest, Sig1StructureTestVectorSuccess) {
+  const Sig1Structure payload = {
+      .context = "Signature1",
+      .body_protected = "body_protected",
+      .external_aad = "external_aad",
+      .payload = "payload",
+  };
+
+  const std::string cbor = EncodeSig1Structure(payload);
+  EXPECT_THAT(
+      cbor, IsBytesEqHexStr(
+                "846a5369676e6174757265314e626f64795f70726f7465637465644c657874"
+                "65726e616c5f616164477061796c6f6164"));
+
+  EXPECT_THAT(DecodeSig1Structure(cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, ProtectedHeaderEdDsa) {
+  const ProtectedHeader payload = {
+      .alg = SigningAlgorithm::kEdDsa,
+      .certificate_chain = {"abcd"},
+  };
+
+  auto cbor = EncodeProtectedHeader(payload);
+  ASSERT_TRUE(cbor.ok());
+  EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, ProtectedHeaderEs256) {
+  const ProtectedHeader payload = {
+      .alg = SigningAlgorithm::kEs256,
+      .certificate_chain = {"abcd"},
+  };
+
+  auto cbor = EncodeProtectedHeader(payload);
+  ASSERT_TRUE(cbor.ok());
+  EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload));
+}
+TEST(SigStructureTest, ProtectedHeaderEs384) {
+  const ProtectedHeader payload = {
+      .alg = SigningAlgorithm::kEs384,
+      .certificate_chain = {"abcd"},
+  };
+
+  auto cbor = EncodeProtectedHeader(payload);
+  ASSERT_TRUE(cbor.ok());
+  EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, ProtectedHeaderEs512) {
+  const ProtectedHeader payload = {
+      .alg = SigningAlgorithm::kEs512,
+      .certificate_chain = {"abcd"},
+  };
+
+  auto cbor = EncodeProtectedHeader(payload);
+  ASSERT_TRUE(cbor.ok());
+  EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, ProtectedHeaderPs256) {
+  const ProtectedHeader payload = {
+      .alg = SigningAlgorithm::kPs256,
+      .certificate_chain = {"abcd"},
+  };
+
+  auto cbor = EncodeProtectedHeader(payload);
+  ASSERT_TRUE(cbor.ok());
+  EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, ProtectedHeaderPs384) {
+  const ProtectedHeader payload = {
+      .alg = SigningAlgorithm::kPs384,
+      .certificate_chain = {"abcd"},
+  };
+
+  auto cbor = EncodeProtectedHeader(payload);
+  ASSERT_TRUE(cbor.ok());
+  EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, ProtectedHeaderPs512) {
+  const ProtectedHeader payload = {
+      .alg = SigningAlgorithm::kPs512,
+      .certificate_chain = {"abcd"},
+  };
+
+  auto cbor = EncodeProtectedHeader(payload);
+  ASSERT_TRUE(cbor.ok());
+  EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, ProtectedHeaderMultipleCerts) {
+  const ProtectedHeader payload = {
+      .alg = SigningAlgorithm::kPs512,
+      .certificate_chain = {"cert1", "cert2"},
+  };
+
+  auto cbor = EncodeProtectedHeader(payload);
+  ASSERT_TRUE(cbor.ok());
+  EXPECT_THAT(DecodeProtectedHeader(*cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, ProtectedHeaderUnsupportedAlgorithm) {
+  // hex for {1: -100, 33: "abcd"}
+  auto cbor = absl::HexStringToBytes("a2013863214461626364");
+  EXPECT_THAT(DecodeProtectedHeader(cbor),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "unsupported COSE algorithm: -100"));
+}
+
+TEST(SigStructureTest, CoseSign1TaggedStructureEncodeNullPayload) {
+  const CoseSign1TaggedStructure payload = {
+      .protected_header = "abc",
+      .signature = "def",
+  };
+  const std::string cbor = EncodeCoseSign1TaggedStructure(payload);
+  EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload));
+}
+TEST(SigStructureTest, CoseSign1TaggedStructureEncodeNonNullPayload) {
+  const CoseSign1TaggedStructure payload = {
+      .protected_header = "abc",
+      .payload = "xyz",
+      .signature = "def",
+  };
+  const std::string cbor = EncodeCoseSign1TaggedStructure(payload);
+  EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload));
+}
+TEST(SigStructureTest, CoseSign1TaggedStructureEncodeTstTokens) {
+  const CoseSign1TaggedStructure payload = {
+      .protected_header = "abc",
+      .unprotected_header = {.sig_tst2 =
+                                 TstContainer{.tst_tokens = {{.val = "val1"},
+                                                             {.val = "val2"}}}},
+      .signature = "def",
+  };
+  const std::string cbor = EncodeCoseSign1TaggedStructure(payload);
+  EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, CoseSign1TaggedStructureEncodeOcspResponses) {
+  const CoseSign1TaggedStructure payload = {
+      .protected_header = "abc",
+      .unprotected_header = {.ocsp_responses = {"ocsp1", "ocsp2"}},
+      .signature = "def",
+  };
+  const std::string cbor = EncodeCoseSign1TaggedStructure(payload);
+  EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, CoseSign1TaggedStructureEncodePad) {
+  const CoseSign1TaggedStructure payload = {
+      .protected_header = "abc",
+      .unprotected_header = {.pad = "padding"},
+      .signature = "def",
+  };
+  const std::string cbor = EncodeCoseSign1TaggedStructure(payload);
+  EXPECT_THAT(DecodeCoseSign1TaggedStructure(cbor), IsOkAndHolds(payload));
+}
+
+TEST(SigStructureTest, CoseSign1TaggedStructureDecodeSignature) {
+  auto protected_headers_b64 =
+      R"(cHJvdGVjdGVkX2hlYWRlcg==)";           // "protected_header"
+  auto timestamp_cms_b64 = R"(dGltZXN0YW1w)";  // "timestamp"
+  auto signature_b64 = R"(c2lnbmF0dXJl)";      // "signature"
+  auto signature = cbor::FromJson(absl::Substitute(
+      R"json(["b64'$0'", {"sigTst2": {"tstTokens": [{"val": "b64'$1'"}]}}, null, "b64'$2'"])json",
+      protected_headers_b64, timestamp_cms_b64, signature_b64));
+  // Prepend the COSE_Sign1 tag (Major type 6 addl value 18).
+  signature = "\xD2" + signature;
+  CoseSign1TaggedStructure expected{
+      .protected_header = Base64Unescape(protected_headers_b64),
+      .unprotected_header =
+          {
+              .sig_tst2 =
+                  TstContainer{
+                      .tst_tokens = {{.val =
+                                          Base64Unescape(timestamp_cms_b64)}},
+                  },
+          },
+      .signature = Base64Unescape(signature_b64),
+  };
+  EXPECT_THAT(DecodeCoseSign1TaggedStructure(signature),
+              IsOkAndHolds(expected));
+  EXPECT_EQ(EncodeCoseSign1TaggedStructure(expected), signature);
+};
+
+TEST(SigStructureTest, DecodeUnprotectedHeaderContainingSigTst) {
+  auto protected_headers_b64 =
+      R"(cHJvdGVjdGVkX2hlYWRlcg==)";           // "protected_header"
+  auto timestamp_cms_b64 = R"(dGltZXN0YW1w)";  // "timestamp"
+  auto signature_b64 = R"(c2lnbmF0dXJl)";      // "signature"
+  auto signature = cbor::FromJson(absl::Substitute(
+      R"json(["b64'$0'", {"sigTst": {"tstTokens": [{"val": "b64'$1'"}]}}, null, "b64'$2'"])json",
+      protected_headers_b64, timestamp_cms_b64, signature_b64));
+  // Prepend the COSE_Sign1 tag (Major type 6 addl value 18).
+  signature = "\xD2" + signature;
+  CoseSign1TaggedStructure expected{
+      .protected_header = Base64Unescape(protected_headers_b64),
+      .unprotected_header =
+          {
+              .sig_tst =
+                  TstContainer{
+                      .tst_tokens = {{.val =
+                                          Base64Unescape(timestamp_cms_b64)}},
+                  },
+          },
+      .signature = Base64Unescape(signature_b64),
+  };
+  EXPECT_THAT(DecodeCoseSign1TaggedStructure(signature),
+              IsOkAndHolds(expected));
+}
+
+TEST(SigStructureTest, DecodeUnprotectedHeaderContainingCertificateChain) {
+  auto protected_headers_b64 =
+      R"(cHJvdGVjdGVkX2hlYWRlcg==)";            // "protected_header"
+  auto timestamp_cms_b64 = R"(dGltZXN0YW1w)";   // "timestamp"
+  auto signature_b64 = R"(c2lnbmF0dXJl)";       // "signature"
+  auto certificate_64 = R"(Y2VydGlmaWNhdGU=)";  // "certificate"
+  auto signature = cbor::FromJson(absl::Substitute(
+      R"json(["b64'$0'", {"sigTst": {"tstTokens": [{"val": "b64'$1'"}]}, "x5chain": ["b64'$2'"]}, null, "b64'$3'"])json",
+      protected_headers_b64, timestamp_cms_b64, certificate_64, signature_b64));
+  // Prepend the COSE_Sign1 tag (Major type 6 addl value 18).
+  signature = "\xD2" + signature;
+  CoseSign1TaggedStructure expected{
+      .protected_header = Base64Unescape(protected_headers_b64),
+      .unprotected_header =
+          {
+              .sig_tst =
+                  TstContainer{
+                      .tst_tokens = {{.val =
+                                          Base64Unescape(timestamp_cms_b64)}},
+                  },
+              .certificate_chain = {Base64Unescape(certificate_64)},
+          },
+      .signature = Base64Unescape(signature_b64),
+  };
+  EXPECT_THAT(DecodeCoseSign1TaggedStructure(signature),
+              IsOkAndHolds(expected));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/cose/simple_cms_parser.cc b/cose/simple_cms_parser.cc
new file mode 100644
index 0000000..fc2cc18
--- /dev/null
+++ b/cose/simple_cms_parser.cc
@@ -0,0 +1,225 @@
+// 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 "cose/simple_cms_parser.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/algorithm/container.h"
+#include "absl/memory/memory.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/cms/certificates.h"
+#include "crypto/default/cms/cms_error_code.h"
+#include "crypto/default/cms/cms_parser.h"
+#include "crypto/default/cms/oids.h"
+#include "crypto/default/cms/verify_signature.h"
+#include "openssl/asn1.h"
+#include "openssl/bytestring.h"
+#include "openssl/mem.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/stack.h"
+#include "openssl/x509.h"
+#include "tsp/constants.h"
+#include "tsp/parsed_timestamp_token.h"
+
+namespace credentio {
+
+namespace {
+
+absl::StatusOr<std::string> GetDer(X509* x509) {
+  uint8_t* der_ptr = nullptr;
+  int der_len = i2d_X509(x509, &der_ptr);
+  bssl::UniquePtr<uint8_t> der(der_ptr);
+
+  if (der_len <= 0) {
+    return absl::InvalidArgumentError(
+        "cannot convert certificate within timestamp token to DER");
+  }
+  return std::string(reinterpret_cast<char*>(der_ptr), der_len);
+}
+
+}  // namespace
+
+SimpleParsedTimestampToken::SimpleParsedTimestampToken(absl::string_view data)
+    : data_(data) {}
+
+absl::StatusOr<std::unique_ptr<SimpleParsedTimestampToken>>
+SimpleParsedTimestampToken::Create(absl::string_view timestamp_token) {
+  auto token =
+      absl::WrapUnique(new SimpleParsedTimestampToken(timestamp_token));
+  std::string error_message;
+  credentio_cms::ErrorCode err = credentio_cms::ParseCms(
+      token->data_.data(), token->data_.size(), &token->cms_, &error_message);
+
+  if (err != credentio_cms::ErrorCode::OK) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("could not parse TimeStampToken cms: ", error_message));
+  }
+
+  if (!credentio_cms::CompareOid(token->cms_.content_type.cbs(),
+                                 credentio_cms::kCtTstInfoOid,
+                                 sizeof(credentio_cms::kCtTstInfoOid))) {
+    return absl::InvalidArgumentError(
+        "TimeStampToken eContentType is not id-ct-TSTInfo");
+  }
+  return token;
+}
+
+absl::StatusOr<const credentio_cms::SignerInfo*>
+SimpleParsedTimestampToken::GetSingleSignerInfo() const {
+  const std::vector<credentio_cms::SignerInfo>& signer_infos = cms_.signers;
+  if (signer_infos.size() != 1) {
+    return absl::InvalidArgumentError(
+        absl::StrFormat("wrong number of signer infos: timestamp has %d "
+                        "signers; exactly 1 is required",
+                        signer_infos.size()));
+  }
+  return &signer_infos[0];
+}
+
+absl::StatusOr<X509*> SimpleParsedTimestampToken::GetSignerCert(
+    const STACK_OF(X509) * certs,
+    const credentio_cms::SignerInfo& signer_info) const {
+  std::vector<X509*> tsa_certs =
+      credentio_cms::GetSignerCertificates(certs, signer_info);
+  if (tsa_certs.empty() || tsa_certs[0] == nullptr) {
+    return absl::UnauthenticatedError(
+        "timestamp does not contain TSA certificate");
+  }
+  return tsa_certs[0];
+}
+
+absl::Status SimpleParsedTimestampToken::VerifySignature() const {
+  ABSL_ASSIGN_OR_RETURN(auto signer_info, GetSingleSignerInfo());
+
+  bssl::UniquePtr<STACK_OF(X509)> certs(
+      credentio_cms::GetAllCertificates(cms_));
+  if (!certs) {
+    return absl::UnauthenticatedError("failed to parse certificates");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto tsa_cert,
+                        GetSignerCert(certs.get(), *signer_info));
+
+  return credentio_cms::VerifySignature(cms_, *signer_info, cms_.content,
+                                        *tsa_cert)
+      .status();
+}
+
+absl::StatusOr<HashAlgorithm>
+SimpleParsedTimestampToken::GetMessageImprintHashAlgorithm() const {
+  ABSL_ASSIGN_OR_RETURN(auto signer_info, GetSingleSignerInfo());
+  return NidToHashAlgorithm(
+      OBJ_cbs2nid(signer_info->digest_algorithm.algorithm_oid.cbs_ptr()));
+}
+
+absl::StatusOr<std::vector<std::string>>
+SimpleParsedTimestampToken::GetCertificateChain() const {
+  ABSL_ASSIGN_OR_RETURN(auto signer_info, GetSingleSignerInfo());
+
+  bssl::UniquePtr<STACK_OF(X509)> certs(
+      credentio_cms::GetAllCertificates(cms_));
+  if (!certs) {
+    return absl::UnauthenticatedError("failed to parse certificates");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto tsa_cert,
+                        GetSignerCert(certs.get(), *signer_info));
+
+  std::vector<std::string> chain;
+  chain.reserve(sk_X509_num(certs.get()) + 1);
+
+  ABSL_ASSIGN_OR_RETURN(auto tsa_der, GetDer(tsa_cert));
+  chain.push_back(std::move(tsa_der));
+
+  for (size_t i = 0; i < sk_X509_num(certs.get()); ++i) {
+    X509* x509 = sk_X509_value(certs.get(), i);
+    if (X509_cmp(x509, tsa_cert) != 0) {
+      ABSL_ASSIGN_OR_RETURN(auto der, GetDer(x509));
+      chain.push_back(std::move(der));
+    }
+  }
+  return chain;
+}
+
+absl::StatusOr<absl::string_view>
+SimpleParsedTimestampToken::GetSigningCertificateV2Bytes() const {
+  ABSL_ASSIGN_OR_RETURN(auto signer_info, GetSingleSignerInfo());
+
+  const auto& attributes = signer_info->signed_attributes;
+  auto v2_attribute = absl::c_find_if(
+      attributes, [](const credentio_cms::Attribute& attribute) -> bool {
+        const CBS* cbs = &attribute.type.cbs();
+        bssl::UniquePtr<ASN1_OBJECT> oid(
+            ASN1_OBJECT_create(NID_undef, CBS_data(cbs), CBS_len(cbs),
+                               /*sn=*/nullptr, /*ln=*/nullptr));
+        if (!oid) {
+          return false;
+        }
+        return OBJ_cmp(IdAaSigningCertificateV2(), oid.get()) == 0;
+      });
+
+  if (v2_attribute == attributes.end()) {
+    return absl::InvalidArgumentError(
+        "invalid signed attributes; TSA did not set the "
+        "\"SigningCertificateV2\" signed attribute");
+  }
+
+  if (v2_attribute->values.size() != 1) {
+    return absl::InvalidArgumentError(
+        absl::StrFormat("SigningCertificateV2 signed attribute contains the "
+                        "wrong number of entries; got %d entries, expect 1",
+                        v2_attribute->values.size()));
+  }
+
+  CBS signing_certificate_cbs = v2_attribute->values[0].cbs();
+  const char* data =
+      reinterpret_cast<const char*>(CBS_data(&signing_certificate_cbs));
+  size_t len = CBS_len(&signing_certificate_cbs);
+  return absl::string_view(data, len);
+}
+
+absl::StatusOr<absl::string_view> SimpleParsedTimestampToken::GetTstInfoBytes()
+    const {
+  if (cms_.content.size() != 1) {
+    return absl::InvalidArgumentError(
+        absl::StrFormat("TSTInfo content contains unexpected number of chunks; "
+                        "got %d chunks, expect 1",
+                        cms_.content.size()));
+  }
+  const CBS& cbs = cms_.content[0].cbs();
+  const char* data = reinterpret_cast<const char*>(CBS_data(&cbs));
+  size_t len = CBS_len(&cbs);
+  return absl::string_view(data, len);
+}
+
+absl::StatusOr<std::unique_ptr<ParsedTimestampToken>>
+SimpleCmsParser::ParseTimestampToken(absl::string_view timestamp_token) const {
+  return SimpleParsedTimestampToken::Create(timestamp_token);
+}
+
+}  // namespace credentio
diff --git a/cose/simple_cms_parser.h b/cose/simple_cms_parser.h
new file mode 100644
index 0000000..8a966ee
--- /dev/null
+++ b/cose/simple_cms_parser.h
@@ -0,0 +1,77 @@
+// 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_COSE_SIMPLE_CMS_PARSER_H_
+#define THIRD_PARTY_CREDENTIO_COSE_SIMPLE_CMS_PARSER_H_
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/cms/cms_parser.h"
+#include "openssl/stack.h"
+#include "openssl/x509.h"
+#include "tsp/cms_parser.h"
+#include "tsp/parsed_timestamp_token.h"
+
+namespace credentio {
+
+// ParsedTimestampToken implementation backed by the SimpleCMS library
+class SimpleParsedTimestampToken : public ParsedTimestampToken {
+ public:
+  static absl::StatusOr<std::unique_ptr<SimpleParsedTimestampToken>> Create(
+      absl::string_view timestamp_token);
+
+  // SimpleParsedTimestampToken is self-referential (cms_ contains pointers into
+  // data_). Copying or moving this object would invalidate those pointers.
+  SimpleParsedTimestampToken(const SimpleParsedTimestampToken&) = delete;
+  SimpleParsedTimestampToken& operator=(const SimpleParsedTimestampToken&) =
+      delete;
+
+  ~SimpleParsedTimestampToken() override = default;
+
+  absl::Status VerifySignature() const override;
+  absl::StatusOr<HashAlgorithm> GetMessageImprintHashAlgorithm() const override;
+  absl::StatusOr<std::vector<std::string>> GetCertificateChain() const override;
+  absl::StatusOr<absl::string_view> GetSigningCertificateV2Bytes()
+      const override;
+  absl::StatusOr<absl::string_view> GetTstInfoBytes() const override;
+
+ private:
+  explicit SimpleParsedTimestampToken(absl::string_view data);
+  absl::StatusOr<const credentio_cms::SignerInfo*> GetSingleSignerInfo() const;
+  absl::StatusOr<X509*> GetSignerCert(
+      const STACK_OF(X509) * certs,
+      const credentio_cms::SignerInfo& signer_info) const;
+
+  const std::string data_;
+  credentio_cms::Content cms_;
+};
+
+// CmsParser implementation backed by the SimpleCMS library
+// (//third_party/simple_cms).
+class SimpleCmsParser : public CmsParser {
+ public:
+  absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> ParseTimestampToken(
+      absl::string_view timestamp_token) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_COSE_SIMPLE_CMS_PARSER_H_
diff --git a/cose/simple_cms_parser_test.cc b/cose/simple_cms_parser_test.cc
new file mode 100644
index 0000000..62a0bae
--- /dev/null
+++ b/cose/simple_cms_parser_test.cc
@@ -0,0 +1,87 @@
+// 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 "cose/simple_cms_parser.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "tsp/parsed_timestamp_token.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::IsEmpty;
+using ::testing::Not;
+
+constexpr absl::string_view kTestTimestampBase64 =
+    "MIIELgYJKoZIhvcNAQcCoIIEHzCCBBsCAQMxDzANBglghkgBZQMEAgEFADBtBgsqhkiG9w0B"
+    "CRABBKBeBFwwWgIBAQYLKoZIhvcSBAGEtwkwLzALBglghkgBZQMEAgEEIEvMhz4zDmm90cO7"
+    "V4KzzQtZsdcDbHNnB3pLBuM40dwzAgEBGA8yMDI1MDMwODAzMDYwN1owAAEB/6CCAdswggHX"
+    "MIIBXKADAgECAhRpQze1ZW5xPO9VZQ1jQeMUoEASTDAKBggqhkjOPQQDAjAmMSQwIgYDVQQD"
+    "DBtHb29nbGUgQzJQQSBUZXN0aW5nIFJvb3QgQ0EwIBcNMjUwMjA0MjE0MjE0WhgPMjEyNTAx"
+    "MTEyMTQyMTRaMCIxIDAeBgNVBAMMF0dvb2dsZSBDMlBBIFRlc3RpbmcgVFNBMFkwEwYHKoZI"
+    "zj0CAQYIKoZIzj0DAQcDQgAEuUuyy7wwl0MJLdRXgI9wjWXiWdDtjpXuMBoBdO3vBtGc3yVu"
+    "NwyZ/NjiaqFzocy0VipiPhR47u22OS/MjY98aaNqMGgwDgYDVR0PAQH/BAQDAgeAMBYGA1Ud"
+    "JQEB/wQMMAoGCCsGAQUFBwMIMB0GA1UdDgQWBBR5bJKsXm0pzYLB7sDOTEAtdVfIjDAfBgNV"
+    "HSMEGDAWgBTTMOrQr2ny9gYyywPGUZGx/KENtzAKBggqhkjOPQQDAgNpADBmAjEA/AIooYzK"
+    "ZhXynaBcMPIlIxscmd8e78W995pZgXprq3AIK3sgV6ElZvKD9/Wn/dDtAjEArTIsjov8VNA9"
+    "FoHedkGbg5zD1BnXvWqKwMp3XBbzeaOYUCn1o0WUW/bJC30DU14RMYIBtTCCAbECAQEwPjAm"
+    "MSQwIgYDVQQDDBtHb29nbGUgQzJQQSBUZXN0aW5nIFJvb3QgQ0ECFGlDN7VlbnE871VlDWNB"
+    "4xSgQBJMMA0GCWCGSAFlAwQCAQUAoIIBAjAvBgkqhkiG9w0BCQQxIgQgN8dNxTh6LWv1EaL+"
+    "ofr4okM1B8iSO/Gl00H5EaCJS+YwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqG"
+    "SIb3DQEJBTEPFw0xNTAzMzAxODAxMjNaMFwGCSqGSIb3DQEJDzFPME0wCwYJYIZIAWUDBAEq"
+    "MAsGCWCGSAFlAwQBFjALBglghkgBZQMEAQIwCgYIKoZIhvcNAwcwCwYJKoZIhvcNAQEHMAsG"
+    "CWCGSAFlAwQCATA3BgsqhkiG9w0BCRACLzEoMCYwJDAiBCDJQ1abcB8vef+N3PQLpUzfC7V8"
+    "WkIdUyUW/U2T9Y4EqDANBgkqhkiG9w0BAQEFAARIMEYCIQC4cdlBBcD7HlrGTj+8Kcfjj2+i"
+    "/xnE9/c61emmfJfTlQIhAKTiTMV71p0tHgFHyxsCIyIPCc96VfgYQ1qBMBolMeHf";
+
+TEST(SimpleCmsParserTest, ParseValidTimestampToken) {
+  std::string timestamp;
+  ASSERT_TRUE(absl::Base64Unescape(kTestTimestampBase64, &timestamp));
+  SimpleCmsParser parser;
+  auto parsed_token = parser.ParseTimestampToken(timestamp);
+  ASSERT_TRUE(parsed_token.ok());
+  std::unique_ptr<ParsedTimestampToken> token = std::move(*parsed_token);
+  ASSERT_NE(token, nullptr);
+
+  EXPECT_THAT(token->VerifySignature(), IsOk());
+  EXPECT_THAT(token->GetMessageImprintHashAlgorithm(),
+              IsOkAndHolds(HashAlgorithm::kSha256));
+  EXPECT_THAT(token->GetCertificateChain(), IsOkAndHolds(Not(IsEmpty())));
+  EXPECT_THAT(token->GetSigningCertificateV2Bytes(),
+              IsOkAndHolds(Not(IsEmpty())));
+  EXPECT_THAT(token->GetTstInfoBytes(), IsOkAndHolds(Not(IsEmpty())));
+}
+
+TEST(SimpleCmsParserTest, ParseInvalidCmsBytes) {
+  SimpleCmsParser parser;
+  EXPECT_THAT(parser.ParseTimestampToken("invalid binary data"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/cose/validation_status_util.h b/cose/validation_status_util.h
new file mode 100644
index 0000000..1d2edae
--- /dev/null
+++ b/cose/validation_status_util.h
@@ -0,0 +1,55 @@
+// 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_COSE_VALIDATION_STATUS_UTIL_H_
+#define THIRD_PARTY_CREDENTIO_COSE_VALIDATION_STATUS_UTIL_H_
+
+#include <type_traits>
+
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+
+struct RecordOptions {
+  absl::string_view url = "";
+  absl::string_view explanation = "";
+};
+
+template <typename StatusCode>
+void RecordStatus(ValidationStatusSet* status_set, StatusCode code,
+                  RecordOptions options = {}) {
+  ValidationStatus* status = nullptr;
+  if constexpr (std::is_same_v<StatusCode, SuccessStatusCode>) {
+    status = status_set->add_successes();
+  } else if constexpr (std::is_same_v<StatusCode, InformationalStatusCode>) {
+    status = status_set->add_informationals();
+  } else if constexpr (std::is_same_v<StatusCode, FailureStatusCode>) {
+    status = status_set->add_failures();
+  }
+  status->set_code(absl::StrCat(code));
+  if (!options.url.empty()) {
+    status->set_url(options.url);
+  }
+  if (!options.explanation.empty()) {
+    status->set_explanation(options.explanation);
+  }
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_COSE_VALIDATION_STATUS_UTIL_H_
diff --git a/cose/verifier.cc b/cose/verifier.cc
new file mode 100644
index 0000000..9e27c89
--- /dev/null
+++ b/cose/verifier.cc
@@ -0,0 +1,89 @@
+// 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 "cose/verifier.h"
+
+#include <memory>
+#include <optional>
+#include <utility>
+
+#include "absl/log/check.h"
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "cose/cose_sign1.h"
+#include "cose/sig_structure.h"
+#include "cose/validation_status_util.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "google/protobuf/timestamp.pb.h"
+#include "proto/cose_verification_result.pb.h"
+#include "proto/signature_info.pb.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+namespace {
+
+class CoseVerifierImpl : public CoseVerifier {
+ public:
+  explicit CoseVerifierImpl(CoseVerifierOptions options)
+      : options_(std::move(options)) {}
+  CoseVerificationResult Verify(absl::string_view cose_signature,
+                                absl::string_view data) const override {
+    CoseVerificationResult result;
+    ValidationStatusSet* status_set = result.mutable_validation_status();
+    absl::StatusOr<CoseSign1TaggedStructure> cose_sign1_struct =
+        DecodeCoseSign1TaggedStructure(cose_signature);
+    if (!cose_sign1_struct.ok()) {
+      RecordStatus(status_set, FailureStatusCode::kSigningCredentialInvalid,
+                   {.url = kClaimSignatureLabel,
+                    .explanation = cose_sign1_struct.status().ToString()});
+      return result;
+    }
+    auto cose_sign1_verifier = CoseSign1Verifier::Create(
+        options_.crypto_read_handler.get(), &DefaultHashCheckerFactory(),
+        options_.clock,
+        CoseSign1Verifier::Options{
+            .check_ocsp_responses = options_.revocation_checks ==
+                                    CoseVerifierRevocationChecks::kEnabled,
+            .verify_signature = options_.signature_checks ==
+                                CoseVerifierSignatureChecks::kEnabled,
+        });
+    std::optional<SignatureInfo> signature_info = cose_sign1_verifier->Verify(
+        *std::move(cose_sign1_struct), data, status_set);
+    if (!signature_info.has_value()) {
+      // Failure has already been recorded to the tracker by CoseSign1::Verify.
+      return result;
+    }
+    *result.mutable_signature_info() = *std::move(signature_info);
+
+    return result;
+  }
+
+ private:
+  const CoseVerifierOptions options_;
+};
+
+}  // namespace
+
+std::unique_ptr<CoseVerifier> CreateCoseVerifier(CoseVerifierOptions options) {
+  CHECK(options.crypto_read_handler);
+  return std::make_unique<CoseVerifierImpl>(std::move(options));
+}
+
+}  // namespace credentio
diff --git a/cose/verifier.h b/cose/verifier.h
new file mode 100644
index 0000000..ba7b04e
--- /dev/null
+++ b/cose/verifier.h
@@ -0,0 +1,69 @@
+// 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_COSE_VERIFIER_H_
+#define THIRD_PARTY_CREDENTIO_COSE_VERIFIER_H_
+
+#include <memory>
+
+#include "absl/base/nullability.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/clock_interface.h"
+#include "crypto/crypto_read_handler.h"
+#include "proto/cose_verification_result.pb.h"
+#include "proto/signature_info.pb.h"
+
+namespace credentio {
+
+class CoseVerifier {
+ public:
+  virtual ~CoseVerifier() = default;
+
+  // Validates the provided COSE_SIGN1 signature (`signature`) against the
+  // provided expected data (`data`).
+  // Returns a CoseVerificationResult, which contains the SignatureInfo
+  // if the signature is valid, and the validation statuses encountered
+  // during verification.
+  virtual CoseVerificationResult Verify(absl::string_view signature,
+                                        absl::string_view data) const = 0;
+};
+
+// Whether claim signatures (by the leaf certificate holder) are checked.
+// This does not affect trust chain validation.
+enum class CoseVerifierSignatureChecks { kDisabledForTest, kEnabled };
+
+// Whether OCSP revocation checks are performed.
+// This does not affect trust chain validation.
+enum class CoseVerifierRevocationChecks { kDisabledForTest, kEnabled };
+
+struct CoseVerifierOptions {
+  std::unique_ptr<CryptoReadHandler> absl_nonnull crypto_read_handler;
+  // Clock to use by the verifier.
+  absl::Clock* clock = &absl::Clock::GetRealClock();
+  // Option to enable/disable signature checks.
+  CoseVerifierSignatureChecks signature_checks =
+      CoseVerifierSignatureChecks::kEnabled;
+  // Option to enable/disable OCSP revocation checks.
+  CoseVerifierRevocationChecks revocation_checks =
+      CoseVerifierRevocationChecks::kEnabled;
+};
+
+// Creates a COSE verifier.
+std::unique_ptr<CoseVerifier> CreateCoseVerifier(
+    CoseVerifierOptions options = {});
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_COSE_VERIFIER_H_
diff --git a/crypto/BUILD b/crypto/BUILD
new file mode 100644
index 0000000..37ee2ca
--- /dev/null
+++ b/crypto/BUILD
@@ -0,0 +1,113 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "algorithms",
+    srcs = ["algorithms.cc"],
+    hdrs = ["algorithms.h"],
+    deps = [
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "algorithms_test",
+    srcs = ["algorithms_test.cc"],
+    deps = [
+        ":algorithms",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "hash",
+    hdrs = ["hash.h"],
+    deps = [
+        ":algorithms",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "cbs_utils",
+    hdrs = ["cbs_utils.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_library(
+    name = "mock_hash",
+    testonly = True,
+    hdrs = ["mock_hash.h"],
+    deps = [
+        ":algorithms",
+        ":hash",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest",
+    ],
+)
+
+cc_library(
+    name = "timestamp_read_handler",
+    hdrs = ["timestamp_read_handler.h"],
+    deps = [
+        "//tsp:verified_timestamp",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "crypto_read_handler",
+    srcs = ["crypto_read_handler.cc"],
+    hdrs = ["crypto_read_handler.h"],
+    deps = [
+        ":algorithms",
+        ":timestamp_read_handler",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_test(
+    name = "crypto_read_handler_test",
+    srcs = ["crypto_read_handler_test.cc"],
+    deps = [
+        ":crypto_read_handler",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/crypto/algorithms.cc b/crypto/algorithms.cc
new file mode 100644
index 0000000..89029d0
--- /dev/null
+++ b/crypto/algorithms.cc
@@ -0,0 +1,49 @@
+// 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 "crypto/algorithms.h"
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+absl::StatusOr<HashAlgorithm> NidToHashAlgorithm(int nid) {
+  HashAlgorithm algorithm = static_cast<HashAlgorithm>(nid);
+  switch (algorithm) {
+    case HashAlgorithm::kSha256:
+    case HashAlgorithm::kSha384:
+    case HashAlgorithm::kSha512:
+      return algorithm;
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("hash algorithm NID is not on C2PA allowlist: ", nid));
+}
+
+absl::StatusOr<HashAlgorithm> ParseHashAlgorithm(absl::string_view txt) {
+  if (txt == "sha256") {
+    return HashAlgorithm::kSha256;
+  } else if (txt == "sha384") {
+    return HashAlgorithm::kSha384;
+  } else if (txt == "sha512") {
+    return HashAlgorithm::kSha512;
+  } else {
+    return absl::InvalidArgumentError("unrecognized hash algorithm name");
+  }
+}
+
+}  // namespace credentio
diff --git a/crypto/algorithms.h b/crypto/algorithms.h
new file mode 100644
index 0000000..b37bb9b
--- /dev/null
+++ b/crypto/algorithms.h
@@ -0,0 +1,117 @@
+// 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_CRYPTO_ALGORITHMS_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_ALGORITHMS_H_
+
+#include <cstdint>
+
+#include "absl/log/log.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Hashing algorithms allowed by the C2PA spec, see
+// https://c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html#_hashing
+// All values must correspond to an OpenSSL NID.
+enum class HashAlgorithm {
+  kSha256 = 672,  // NID_sha256
+  kSha384 = 673,  // NID_sha384
+  kSha512 = 674,  // NID_sha512
+};
+
+inline uint64_t HashSizeBytes(HashAlgorithm algorithm) {
+  switch (algorithm) {
+    case credentio::HashAlgorithm::kSha256:
+      return 32;
+    case credentio::HashAlgorithm::kSha512:
+      return 64;
+    case credentio::HashAlgorithm::kSha384:
+      return 48;
+  }
+  LOG(DFATAL) << "unknown HashAlgorithm enum: " << static_cast<int>(algorithm);
+  return 0;
+}
+
+absl::StatusOr<HashAlgorithm> NidToHashAlgorithm(int nid);
+absl::StatusOr<HashAlgorithm> ParseHashAlgorithm(absl::string_view txt);
+
+template <typename Sink>
+void AbslStringify(Sink& sink, HashAlgorithm algorithm) {
+  switch (algorithm) {
+    case HashAlgorithm::kSha256:
+      sink.Append("sha256");
+      return;
+    case HashAlgorithm::kSha384:
+      sink.Append("sha384");
+      return;
+    case HashAlgorithm::kSha512:
+      sink.Append("sha512");
+      return;
+  }
+  LOG(DFATAL) << "unknown HashAlgorithm enum: " << static_cast<int>(algorithm);
+  sink.Append(
+      absl::StrCat("UNKNOWN_HASH_ALGORITHM_", static_cast<int>(algorithm)));
+}
+
+// IANA registry: https://www.iana.org/assignments/cose/cose.xhtml#algorithms
+// C2PA accepted list:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signature_algorithms
+enum class SigningAlgorithm {
+  kEs256,  // ECDSA with SHA-256
+  kEs384,  // ECDSA with SHA-384
+  kEs512,  // ECDSA with SHA-512
+  kPs256,  // RSASSA-PSS using SHA-256 and MGF1 with SHA-256
+  kPs384,  // RSASSA-PSS using SHA-384 and MGF1 with SHA-384
+  kPs512,  // RSASSA-PSS using SHA-512 and MGF1 with SHA-512
+  kEdDsa,  // Edwards-Curve DSA
+};
+
+template <typename Sink>
+inline void AbslStringify(Sink& sink, const SigningAlgorithm& algorithm) {
+  switch (algorithm) {
+    case SigningAlgorithm::kEs256:
+      sink.Append("ES256");
+      return;
+    case SigningAlgorithm::kEs384:
+      sink.Append("ES384");
+      return;
+    case SigningAlgorithm::kEs512:
+      sink.Append("ES512");
+      return;
+    case SigningAlgorithm::kPs256:
+      sink.Append("PS256");
+      return;
+    case SigningAlgorithm::kPs384:
+      sink.Append("PS384");
+      return;
+    case SigningAlgorithm::kPs512:
+      sink.Append("PS512");
+      return;
+    case SigningAlgorithm::kEdDsa:
+      sink.Append("EdDSA");
+      return;
+  }
+  LOG(DFATAL) << "unknown SigningAlgorithm enum: "
+              << static_cast<int>(algorithm);
+  sink.Append(
+      absl::StrCat("UNKNOWN_SIGNING_ALGORITHM_", static_cast<int>(algorithm)));
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_ALGORITHMS_H_
diff --git a/crypto/algorithms_test.cc b/crypto/algorithms_test.cc
new file mode 100644
index 0000000..c8f6aec
--- /dev/null
+++ b/crypto/algorithms_test.cc
@@ -0,0 +1,118 @@
+// 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 "crypto/algorithms.h"
+
+#include <sys/stat.h>
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+
+TEST(SigningAlgorithmTest, StringifyOk) {
+  EXPECT_THAT(absl::StrCat(SigningAlgorithm::kEs256), Eq("ES256"));
+  EXPECT_THAT(absl::StrCat(SigningAlgorithm::kEs384), Eq("ES384"));
+  EXPECT_THAT(absl::StrCat(SigningAlgorithm::kEs512), Eq("ES512"));
+  EXPECT_THAT(absl::StrCat(SigningAlgorithm::kPs256), Eq("PS256"));
+  EXPECT_THAT(absl::StrCat(SigningAlgorithm::kPs384), Eq("PS384"));
+  EXPECT_THAT(absl::StrCat(SigningAlgorithm::kPs512), Eq("PS512"));
+  EXPECT_THAT(absl::StrCat(SigningAlgorithm::kEdDsa), Eq("EdDSA"));
+}
+
+TEST(SigningAlgorithmTest, StringifyUnrecognized) {
+  std::string s;
+  EXPECT_DEBUG_DEATH(
+      {
+        s = absl::StrCat(static_cast<SigningAlgorithm>(-999));
+        EXPECT_THAT(s, Eq("UNKNOWN_SIGNING_ALGORITHM_-999"));
+      },
+      "unknown SigningAlgorithm enum: -999");
+}
+
+TEST(HashAlgorithmTest, StringifyOk) {
+  EXPECT_THAT(absl::StrCat(HashAlgorithm::kSha256), Eq("sha256"));
+  EXPECT_THAT(absl::StrCat(HashAlgorithm::kSha384), Eq("sha384"));
+  EXPECT_THAT(absl::StrCat(HashAlgorithm::kSha512), Eq("sha512"));
+}
+
+TEST(HashAlgorithmTest, StringifyUnrecognized) {
+  std::string s;
+  EXPECT_DEBUG_DEATH(
+      {
+        s = absl::StrCat(static_cast<HashAlgorithm>(-999));
+        EXPECT_THAT(s, Eq("UNKNOWN_HASH_ALGORITHM_-999"));
+      },
+      "unknown HashAlgorithm enum: -999");
+}
+
+TEST(HashSizeTest, GetSizeOk) {
+  EXPECT_THAT(HashSizeBytes(HashAlgorithm::kSha256), Eq(32));
+}
+
+TEST(HashSizeTest, GetSizeUnrecognized) {
+  uint64_t size;
+  EXPECT_DEBUG_DEATH(
+      {
+        size = HashSizeBytes(static_cast<HashAlgorithm>(-999));
+        EXPECT_THAT(size, Eq(0));
+      },
+      "unknown HashAlgorithm enum: -999");
+}
+
+TEST(NidToHashAlgorithmTest, Ok) {
+  EXPECT_THAT(NidToHashAlgorithm(672),
+              IsOkAndHolds(Eq(HashAlgorithm::kSha256)));
+  EXPECT_THAT(NidToHashAlgorithm(673),
+              IsOkAndHolds(Eq(HashAlgorithm::kSha384)));
+  EXPECT_THAT(NidToHashAlgorithm(674),
+              IsOkAndHolds(Eq(HashAlgorithm::kSha512)));
+}
+
+TEST(NidToHashAlgorithmTest, Invalid) {
+  EXPECT_THAT(NidToHashAlgorithm(1),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("algorithm NID is not on C2PA allowlist")));
+}
+
+TEST(ParseHashAlgorithmTest, Ok) {
+  EXPECT_THAT(ParseHashAlgorithm("sha256"),
+              IsOkAndHolds(Eq(HashAlgorithm::kSha256)));
+  EXPECT_THAT(ParseHashAlgorithm("sha384"),
+              IsOkAndHolds(Eq(HashAlgorithm::kSha384)));
+  EXPECT_THAT(ParseHashAlgorithm("sha512"),
+              IsOkAndHolds(Eq(HashAlgorithm::kSha512)));
+}
+
+TEST(ParseHashAlgorithmTest, Invalid) {
+  EXPECT_THAT(ParseHashAlgorithm("sha1"),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("unrecognized hash algorithm name")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/cbs_utils.h b/crypto/cbs_utils.h
new file mode 100644
index 0000000..b572e71
--- /dev/null
+++ b/crypto/cbs_utils.h
@@ -0,0 +1,58 @@
+// 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.
+//
+
+// Common conversions for working with OpenSSL CBS structs
+#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_CBS_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_CBS_UTILS_H_
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"  // IWYU pragma: keep, CBS in base.h is incomplete
+#include "openssl/mem.h"  // IWYU pragma: keep, `bssl::UniquePtr` in base.h is incomplete
+
+namespace credentio {
+
+inline absl::string_view ToStringView(const CBS& cbs) {
+  return absl::string_view(reinterpret_cast<const char*>(cbs.data), cbs.len);
+}
+
+// Returns a CBS that points to the same underlying array as `sv`, Caller must
+// ensure that the backing array outlives the returned CBS.
+inline CBS FromStringView(absl::string_view sv) {
+  CBS cbs;
+  CBS_init(&cbs, reinterpret_cast<const uint8_t*>(sv.data()), sv.length());
+  return cbs;
+}
+
+// Finishes `cbb` and copies the final content to a string.
+inline absl::StatusOr<std::string> FinishToString(CBB* cbb) {
+  uint8_t* data;
+  size_t len;
+  if (!CBB_finish(cbb, &data, &len)) {
+    return absl::InternalError("CBB_finish(...) failed");
+  }
+  bssl::UniquePtr<uint8_t> data_uniq(data);
+  return std::string(reinterpret_cast<char*>(data), len);
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_CBS_UTILS_H_
diff --git a/crypto/crypto_read_handler.cc b/crypto/crypto_read_handler.cc
new file mode 100644
index 0000000..b3409b6
--- /dev/null
+++ b/crypto/crypto_read_handler.cc
@@ -0,0 +1,69 @@
+// 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 "crypto/crypto_read_handler.h"
+
+#include <string>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+bool AbslParseFlag(absl::string_view text,
+                   CryptoReadHandler::TrustEnvironment* trust,
+                   std::string* error) {
+  if (text == "prod") {
+    *trust = CryptoReadHandler::TrustEnvironment::kProd;
+    return true;
+  }
+  if (text == "qual") {
+    *trust = CryptoReadHandler::TrustEnvironment::kQualOnly;
+    return true;
+  }
+  if (text == "qual_and_prod") {
+    *trust = CryptoReadHandler::TrustEnvironment::kQualAndProd;
+    return true;
+  }
+  if (text == "dev") {
+    *trust = CryptoReadHandler::TrustEnvironment::kDevOnly;
+    return true;
+  }
+  if (text == "any") {
+    *trust = CryptoReadHandler::TrustEnvironment::kAny;
+    return true;
+  }
+  *error = "unknown trust environment";
+  return false;
+}
+
+std::string AbslUnparseFlag(CryptoReadHandler::TrustEnvironment trust) {
+  switch (trust) {
+    case CryptoReadHandler::TrustEnvironment::kProd:
+      return "prod";
+    case CryptoReadHandler::TrustEnvironment::kQualOnly:
+      return "qual";
+    case CryptoReadHandler::TrustEnvironment::kQualAndProd:
+      return "qual_and_prod";
+    case CryptoReadHandler::TrustEnvironment::kDevOnly:
+      return "dev";
+    case CryptoReadHandler::TrustEnvironment::kAny:
+      return "any";
+    case CryptoReadHandler::TrustEnvironment::kUnspecified:
+      break;
+  }
+  return "unknown";
+}
+
+}  // namespace credentio
diff --git a/crypto/crypto_read_handler.h b/crypto/crypto_read_handler.h
new file mode 100644
index 0000000..d2ab5ea
--- /dev/null
+++ b/crypto/crypto_read_handler.h
@@ -0,0 +1,181 @@
+// 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_CRYPTO_CRYPTO_READ_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_READ_HANDLER_H_
+
+#include <cstddef>
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "crypto/timestamp_read_handler.h"
+
+namespace credentio {
+
+// A parsed public-key certificate with optional supporting intermediate certs.
+class ParsedCertificates {
+ public:
+  virtual ~ParsedCertificates() = default;
+
+  // Verifies that `signature` applies to `data`, using the public key from the
+  // leaf certificate. This function can be used for both claim and OCSP
+  // response signatures. Performs no trust checks.
+  //
+  // Returns:
+  //   - INVALID_ARGUMENT if the signature is invalid.
+  virtual absl::Status VerifySignature(absl::string_view signature,
+                                       absl::string_view data,
+                                       SigningAlgorithm algorithm) const = 0;
+
+  // Verifies the trustworthiness of the leaf certificate for claim signing, by
+  // finding an ordered chain of valid certificates to a trusted root,
+  // making use of the provided intermediate certificates as needed. All
+  // certificate checks are performed in accordance with
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements,
+  // and validity period checks are performed based on the provided time.
+  //
+  // Returns:
+  //   - UNAUTHENTICATED if the certificate cannot be trusted.
+  //   - OUT_OF_RANGE if `content_time` is outside the validity period of a
+  //     certificate.
+  //   - INVALID_ARGUMENT if a certificate is invalid.
+  //   - UNAVAILABLE if the root trust list cannot be accessed (only possible on
+  //     platforms where it is not built into the binary).
+  //   - The verified trust chain from leaf to root (including the relevant
+  //     trust anchor's cert) if the verification is successful.
+  virtual absl::StatusOr<std::vector<std::string>> VerifyClaimSignerTrust(
+      absl::Time content_time) const = 0;
+
+  // Returns the number of certificates in the chain. Index 0 is the leaf
+  // certificate, but the order of the other certificates is not guaranteed
+  // and not all of them are necessarily part of the chain to a trust anchor
+  // that a successful call to VerifyClaimSignerTrust finds. There is always at
+  // least 1 certificate.
+  virtual size_t GetCertificateCount() const = 0;
+
+  // Returns the distinguished name of the subject of the certificate
+  // (RFC 2253 format). Index 0 represents the leaf certificate.
+  virtual absl::StatusOr<std::string> GetSubject(size_t index) const = 0;
+  // Returns the distinguished name of the issuer of the certificate
+  // (RFC 2253 format).
+  virtual absl::StatusOr<std::string> GetIssuer(size_t index) const = 0;
+
+  // Returns the certificates's start time (notBefore).
+  virtual absl::StatusOr<absl::Time> GetStartTime(size_t index) const = 0;
+
+  // Returns the certificate's end time (notAfter)
+  virtual absl::StatusOr<absl::Time> GetEndTime(size_t index) const = 0;
+  // Returns the certificate's serial number as a lowercase hex string.
+  virtual absl::StatusOr<std::string> GetSerialNumberHex(
+      size_t index) const = 0;
+
+  // Returns the certificate's C2PA assurance level as a string (e.g.,
+  // "1.3.6.1.4.1.62558.3.10"), if it is present and can be parsed.
+  // This is only relevant for the leaf certificate (index 0).
+  virtual absl::StatusOr<std::string> GetAssuranceLevel(size_t index) const = 0;
+
+  // Returns the certificate's C2PA conforming product ID.
+  // This is only relevant for the leaf certificate (index 0).
+  virtual absl::StatusOr<std::string> GetConformingProductId(
+      size_t index) const = 0;
+};
+
+// Represents the certificate revocation status returned by the OCSP
+// responder, as defined in RFC 6960 (Section 2.2).
+enum class OCSPRevocationStatus {
+  kUnknown,  // The responder does not know the status of the certificate.
+  kGood,     // The certificate is not revoked (valid).
+  kRevoked,  // The certificate has been revoked.
+};
+
+// Encapsulates the crypto operation involved in claim verification.
+class CryptoReadHandler : public TimestampReadHandler {
+ public:
+  enum class TrustEnvironment {
+    kUnspecified,
+    // Trust any certificates, i.e., skip claim signer and timestamp trust
+    // checks.
+    kAny,
+    // Trust dev certificates only.
+    kDevOnly,
+    // Trust qual certificates for signers and prod certificates for timestamps.
+    kQualOnly,
+    // Trust qual and prod certificates.
+    kQualAndProd,
+    // Trust prod certificates only.
+    kProd,
+  };
+
+  ~CryptoReadHandler() override = default;
+
+  // Parses a set of DER-encoded certificates, the first of which is the
+  // leaf certificate.
+  virtual absl::StatusOr<std::unique_ptr<ParsedCertificates>>
+  ParseCertificatesDer(
+      absl::Span<const absl::string_view> certificates) const = 0;
+
+  // Verifies the signature and validity of the DER-encoded OCSP response
+  // against the provided issuer certificate (`issuer_certificate_der`) or
+  // an authorized responder, and returns the revocation status of the target
+  // certificate (`certificate_der`) at the specified verification time
+  // (`verify_time`).
+  //
+  // The verification process ensures that the OCSP response is correctly signed
+  // by the issuer or a delegated responder, conforms to RFC 6960 requirements,
+  // matches the target certificate, and is valid at `verify_time`.
+  //
+  // Arguments:
+  //   ocsp_response_der: The DER-encoded OCSP response.
+  //   certificate_der: The DER-encoded X.509 certificate whose revocation
+  //     status is being checked.
+  //   issuer_certificate_der: The DER-encoded X.509 certificate of the issuer
+  //     that signed `certificate_der`.
+  //   verify_time: The time at which the OCSP response validity and revocation
+  //     status are evaluated.
+  //
+  // Returns:
+  //   - INVALID_ARGUMENT if `certificate_der`, `issuer_certificate_der`, or the
+  //     OCSP response is malformed or invalid.
+  //   - UNAUTHENTICATED if the OCSP response signature fails verification or
+  //     if no matching response is found for `certificate_der`.
+  //   - OUT_OF_RANGE if `verify_time` is outside the valid time window of the
+  //     OCSP response (e.g., based on `thisUpdate` and `nextUpdate`).
+  //   - The verified `OCSPRevocationStatus` (kGood, kRevoked, or kUnknown) if
+  //     verification is successful.
+  virtual absl::StatusOr<OCSPRevocationStatus> VerifyOcspResponse(
+      absl::string_view ocsp_response_der, absl::string_view certificate_der,
+      absl::string_view issuer_certificate_der,
+      absl::Time verify_time) const = 0;
+
+  // Returns the trust environment of this handler.
+  virtual TrustEnvironment trust_environment() const = 0;
+};
+
+bool AbslParseFlag(absl::string_view text,
+                   CryptoReadHandler::TrustEnvironment* trust,
+                   std::string* error);
+
+std::string AbslUnparseFlag(CryptoReadHandler::TrustEnvironment trust);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_READ_HANDLER_H_
diff --git a/crypto/crypto_read_handler_test.cc b/crypto/crypto_read_handler_test.cc
new file mode 100644
index 0000000..0a3d8b5
--- /dev/null
+++ b/crypto/crypto_read_handler_test.cc
@@ -0,0 +1,71 @@
+// 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 "crypto/crypto_read_handler.h"
+
+#include <string>
+
+#include "absl/strings/string_view.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+TEST(CryptoReadHandlerTest, AbslParseFlagValid) {
+  CryptoReadHandler::TrustEnvironment trust;
+  std::string error;
+
+  EXPECT_TRUE(AbslParseFlag("prod", &trust, &error));
+  EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kProd);
+
+  EXPECT_TRUE(AbslParseFlag("qual", &trust, &error));
+  EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kQualOnly);
+
+  EXPECT_TRUE(AbslParseFlag("qual_and_prod", &trust, &error));
+  EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kQualAndProd);
+
+  EXPECT_TRUE(AbslParseFlag("dev", &trust, &error));
+  EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kDevOnly);
+
+  EXPECT_TRUE(AbslParseFlag("any", &trust, &error));
+  EXPECT_EQ(trust, CryptoReadHandler::TrustEnvironment::kAny);
+
+  EXPECT_TRUE(error.empty());
+}
+
+TEST(CryptoReadHandlerTest, AbslParseFlagInvalid) {
+  CryptoReadHandler::TrustEnvironment trust;
+  std::string error;
+
+  EXPECT_FALSE(AbslParseFlag("invalid", &trust, &error));
+  EXPECT_EQ(error, "unknown trust environment");
+}
+
+TEST(CryptoReadHandlerTest, AbslUnparseFlag) {
+  EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kProd),
+            "prod");
+  EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kQualOnly),
+            "qual");
+  EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kQualAndProd),
+            "qual_and_prod");
+  EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kDevOnly),
+            "dev");
+  EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kAny), "any");
+  EXPECT_EQ(AbslUnparseFlag(CryptoReadHandler::TrustEnvironment::kUnspecified),
+            "unknown");
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/default/BUILD b/crypto/default/BUILD
new file mode 100644
index 0000000..85a1f98
--- /dev/null
+++ b/crypto/default/BUILD
@@ -0,0 +1,285 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "hasher",
+    srcs = ["hasher.cc"],
+    hdrs = ["hasher.h"],
+    deps = [
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:absl_log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_test(
+    name = "hasher_test",
+    srcs = ["hasher_test.cc"],
+    deps = [
+        ":hasher",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "eku_verifier",
+    srcs = ["eku_verifier.cc"],
+    hdrs = ["eku_verifier.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_test(
+    name = "eku_verifier_test",
+    srcs = ["eku_verifier_test.cc"],
+    deps = [
+        ":eku_verifier",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "compliance_checker",
+    srcs = ["compliance_checker.cc"],
+    hdrs = ["compliance_checker.h"],
+    deps = [
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_test(
+    name = "compliance_checker_test",
+    srcs = ["compliance_checker_test.cc"],
+    deps = [
+        ":compliance_checker",
+        "//testing:crypto_testing",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "x509_certificate",
+    srcs = ["x509_certificate.cc"],
+    hdrs = ["x509_certificate.h"],
+    deps = [
+        ":compliance_checker",
+        "//crypto:algorithms",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@boringssl//:crypto",
+        "@tink_cc//proto:common_cc_proto",
+        "@tink_cc//tink:cleartext_keyset_handle",
+        "@tink_cc//tink:public_key_verify",
+        "@tink_cc//tink/signature:config_v0",
+        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+    ],
+)
+
+cc_test(
+    name = "x509_certificate_test",
+    srcs = ["x509_certificate_test.cc"],
+    deps = [
+        ":x509_certificate",
+        "//crypto:algorithms",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+        "@tink_cc//proto:common_cc_proto",
+        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+    ],
+)
+
+cc_library(
+    name = "pem",
+    srcs = ["pem.cc"],
+    hdrs = ["pem.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_test(
+    name = "pem_test",
+    srcs = ["pem_test.cc"],
+    deps = [
+        ":pem",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "trust_store",
+    srcs = ["trust_store.cc"],
+    hdrs = ["trust_store.h"],
+    deps = [
+        ":pem",
+        "//proto:signature_info_cc_proto",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:pki",
+        "@protobuf//:timestamp_cc_proto",
+    ],
+)
+
+cc_library(
+    name = "timestamp_verifier",
+    srcs = ["timestamp_verifier.cc"],
+    hdrs = ["timestamp_verifier.h"],
+    deps = [
+        ":hasher",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//tsp:parsed_timestamp_token",
+        "//tsp:timestamp_parsing",
+        "//tsp:verified_timestamp",
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log:absl_log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+        "@boringssl//:pki",
+    ],
+)
+
+cc_test(
+    name = "trust_store_test",
+    srcs = ["trust_store_test.cc"],
+    data = ["//testing/testdata/keys"],
+    deps = [
+        ":trust_store",
+        "//testing:test_file_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_test(
+    name = "timestamp_verifier_test",
+    srcs = ["timestamp_verifier_test.cc"],
+    deps = [
+        ":timestamp_verifier",
+        ":trust_store",
+        "//crypto:algorithms",
+        "//crypto:cbs_utils",
+        "//proto:validation_result_cc_proto",
+        "//tsp:constants",
+        "//tsp:parsed_timestamp_token",
+        "//tsp:test_helpers",
+        "//tsp:verified_timestamp",
+        "@abseil-cpp//absl/algorithm:container",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+        "@boringssl//:pki",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "default_crypto_read_handler",
+    srcs = ["default_crypto_read_handler.cc"],
+    hdrs = ["default_crypto_read_handler.h"],
+    deps = [
+        ":eku_verifier",
+        ":trust_store",
+        ":x509_certificate",
+        "//cose:simple_cms_parser",
+        "//crypto:algorithms",
+        "//crypto:crypto_read_handler",
+        "//tsp:timestamp_verifier",
+        "//tsp:verified_timestamp",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/log:absl_log",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+        "@boringssl//:pki",
+    ],
+)
diff --git a/crypto/default/cms/BUILD b/crypto/default/cms/BUILD
new file mode 100644
index 0000000..3ab85a1
--- /dev/null
+++ b/crypto/default/cms/BUILD
@@ -0,0 +1,135 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "cms_parser",
+    srcs = [
+        "ber_parser.cc",
+        "cms_parser.cc",
+        "oids.cc",
+    ],
+    hdrs = [
+        "ber_parser.h",
+        "cms_error_code.h",
+        "cms_parser.h",
+        "oids.h",
+    ],
+    deps = [
+        "@abseil-cpp//absl/strings",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_test(
+    name = "cms_parser_test",
+    srcs = ["cms_parser_test.cc"],
+    data = glob([
+        "testdata/**",
+    ]),
+    deps = [
+        ":cms_parser",
+        ":cms_test_helper",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "cms_test_helper",
+    testonly = True,
+    srcs = ["cms_test_helper.cc"],
+    hdrs = ["cms_test_helper.h"],
+    deps = [
+        ":cms_parser",
+        "//testing:test_file_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_library(
+    name = "verify_signature",
+    srcs = ["verify_signature.cc"],
+    hdrs = ["verify_signature.h"],
+    deps = [
+        ":cms_parser",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_test(
+    name = "ber_parser_test",
+    srcs = ["ber_parser_test.cc"],
+    deps = [
+        ":cms_parser",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_test(
+    name = "verify_signature_test",
+    srcs = ["verify_signature_test.cc"],
+    data = glob(["testdata/**"]),
+    deps = [
+        ":certificates",
+        ":cms_parser",
+        ":cms_test_helper",
+        ":verify_signature",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "certificates",
+    srcs = ["certificates.cc"],
+    hdrs = ["certificates.h"],
+    deps = [
+        ":cms_parser",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_test(
+    name = "certificates_test",
+    srcs = ["certificates_test.cc"],
+    data = glob(["testdata/**"]),
+    deps = [
+        ":certificates",
+        ":cms_parser",
+        ":cms_test_helper",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/strings",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/crypto/default/cms/ber_parser.cc b/crypto/default/cms/ber_parser.cc
new file mode 100644
index 0000000..221b543
--- /dev/null
+++ b/crypto/default/cms/ber_parser.cc
@@ -0,0 +1,266 @@
+// 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 "crypto/default/cms/ber_parser.h"
+
+#include <cstring>
+#include <limits>
+#include <string>
+#include <vector>
+
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/default/cms/cms_error_code.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+
+namespace credentio_cms {
+
+BerParser::BerParser(const uint8_t* data, size_t length) {
+  CBS cbs;
+  CBS_init(&cbs, data, length);
+  scopes_.push_back(Scope(cbs, false));
+  start_ = data;
+}
+
+void BerParser::SetElementName(absl::string_view text) {
+  scopes_.back().element_name = text;
+}
+
+bool BerParser::ok() const { return status_ == ErrorCode::OK; }
+
+ErrorCode BerParser::status() const { return status_; }
+
+std::string BerParser::error_message() const { return error_message_; }
+
+// Checks that the next element has the expected tag. If the element is
+// constructed the parser will read from it until CloseTag is called.
+void BerParser::GetTag(unsigned expected_tag) {
+  unsigned tag = 0;
+  GetTagInternal(&tag, nullptr, nullptr, false /* raw */);
+  CompareTag(tag, expected_tag, nullptr /* out */);
+}
+
+bool BerParser::GetOptionalTag(unsigned expected_tag) {
+  if (!Peek(expected_tag)) {
+    return false;
+  }
+  GetTag(expected_tag);
+  return ok();
+}
+
+void BerParser::GetElementData(unsigned expected_tag, CBS* out) {
+  if (!IsValid(out)) {
+    return;
+  }
+  unsigned tag = 0;
+  GetTagInternal(&tag, out, nullptr /* headers */, false /* raw tag */);
+  CompareTag(tag, expected_tag, out);
+}
+
+bool BerParser::GetOptionalElementData(unsigned expected_tag, CBS* out) {
+  if (!IsValid(out) || !Peek(expected_tag)) {
+    return false;
+  }
+  GetElementData(expected_tag, out);
+  return ok();
+}
+
+void BerParser::GetAnyElementAndSkipChildren(CBS* out) {
+  if (!IsValid(out)) {
+    return;
+  }
+  unsigned tag;
+  GetTagInternal(&tag, nullptr, out, true /* raw */);
+}
+
+bool BerParser::Peek(unsigned expected_tag) const {
+  if (!ok()) {
+    return false;
+  }
+  const Scope& scope = scopes_.back();
+  return CBS_peek_asn1_tag(&scope.data, expected_tag) == 1;
+}
+
+bool BerParser::IsNullOrEmpty() {
+  if (!ok()) {
+    return true;
+  }
+  Scope& scope = scopes_.back();
+  if (CBS_len(&scope.data) == 0) {
+    return true;
+  }
+  if (CBS_peek_asn1_tag(&scope.data, CBS_ASN1_NULL) == 1) {
+    if (CBS_get_asn1(&scope.data, nullptr, CBS_ASN1_NULL) != 1) {
+      SetError(ErrorCode::ASN1_PARSING_ERROR, "Failed to read NULL element ");
+    }
+    return true;
+  }
+  return scope.indefinite && IsEndOfContent();
+}
+
+int BerParser::GetInt() {
+  if (!ok()) {
+    // No-op if there was already an error.
+    return 0;
+  }
+  Scope& scope = scopes_.back();
+  uint64_t v;
+  if (CBS_get_asn1_uint64(&scope.data, &v) != 1 ||
+      v > std::numeric_limits<int>::max()) {
+    SetError(ErrorCode::ASN1_PARSING_ERROR, "Failed to get an int ");
+    return 0;
+  }
+  return static_cast<int>(v);
+}
+
+void BerParser::EndConstructed() {
+  if (!ok()) {
+    return;
+  }
+  if (scopes_.size() < 2) {
+    // There should always be at least one element in the scope stack, this
+    // would pop the last one.
+    SetError(ErrorCode::ASN1_PARSING_ERROR, "Unexpected EndConstructed call ");
+    return;
+  }
+
+  Scope& scope = scopes_.back();
+  if (!scope.indefinite) {
+    if (CBS_len(&scope.data) > 0) {
+      SetError(ErrorCode::ASN1_PARSING_ERROR,
+               "Found data remaining while expecting the end of the current "
+               "element ");
+    }
+    scopes_.pop_back();
+    return;
+  }
+  if (!IsEndOfContent() || CBS_skip(&scope.data, 2) != 1) {
+    SetError(ErrorCode::ASN1_PARSING_ERROR,
+             "Error while expecting the end of an indefinite length element ");
+    return;
+  }
+  const auto end = CBS_data(&scope.data);
+  scopes_.pop_back();
+  const auto previous = CBS_data(&scopes_.back().data);
+  if (end < previous || CBS_skip(&scopes_.back().data, end - previous) != 1) {
+    SetError(
+        ErrorCode::ASN1_PARSING_ERROR,
+        "Failed to seek past the end of the current indefinite length tag ");
+  }
+}
+
+bool BerParser::IsValid(CBS* out) {
+  if (out == nullptr) {
+    SetError(ErrorCode::INVALID_PARAMETER,
+             "Caller error, null pointer passed to IsValid ");
+    return false;
+  }
+  CBS_init(out, nullptr, 0);
+  return status_ == ErrorCode::OK;
+}
+
+bool BerParser::IsEndOfContent() {
+  Scope& scope = scopes_.back();
+  return CBS_len(&scope.data) >= 2 &&
+         memcmp(CBS_data(&scope.data), "\0\0", 2) == 0;
+}
+
+void BerParser::GetTagInternal(unsigned* tag, CBS* value, CBS* header_and_value,
+                               bool raw_tag) {
+  if (!ok()) {
+    return;
+  }
+  Scope& scope = scopes_.back();
+  size_t header_length;
+  CBS out;
+  int indefinite_length;
+  if (CBS_get_any_ber_asn1_element(&scope.data, &out, tag, &header_length,
+                                   /*out_ber_found=*/nullptr,
+                                   &indefinite_length) != 1) {
+    SetError(ErrorCode::ASN1_PARSING_ERROR, "Parsing error ");
+    return;
+  }
+
+  if (indefinite_length && (value != nullptr || header_and_value != nullptr)) {
+    SetError(
+        ErrorCode::ASN1_PARSING_ERROR,
+        "Indefinite length not supported when the element data is needed ");
+    return;
+  }
+
+  if (header_and_value != nullptr) {
+    *header_and_value = out;
+  }
+  if (value != nullptr) {
+    *value = out;
+    if (CBS_skip(value, header_length) != 1) {
+      SetError(ErrorCode::ASN1_PARSING_ERROR, "Failed to skip the header ");
+      return;
+    }
+  }
+
+  if (raw_tag || (*tag & CBS_ASN1_CONSTRUCTED) == 0) {
+    return;
+  }
+  // If the tag is constructed, start a new scope containing either the
+  // element we just read (definite length) or all the data remaining if the
+  // length is unknown.
+  if (indefinite_length) {
+    scopes_.push_back(Scope(scope.data, indefinite_length));
+  } else {
+    if (CBS_skip(&out, header_length) != 1) {
+      SetError(ErrorCode::ASN1_PARSING_ERROR, "Failed to read the header ");
+      return;
+    }
+    scopes_.push_back(Scope(out, indefinite_length));
+  }
+}
+
+void BerParser::CompareTag(unsigned tag, unsigned expected_tag, CBS* out) {
+  if (ok() && tag != expected_tag) {
+    std::string message =
+        absl::StrCat("Got tag ", tag, " instead of tag ", expected_tag);
+
+    SetError(ErrorCode::ASN1_PARSING_ERROR, message);
+    if (out != nullptr) {
+      CBS_init(out, nullptr, 0);
+    }
+  }
+}
+
+void BerParser::SetError(ErrorCode status, absl::string_view text) {
+  if (!ok()) {
+    // Don't clear existing errors.
+    return;
+  }
+  status_ = status;
+  error_message_ =
+      absl::StrCat(text, " at offset: ", GetCurrentOffset(), " in scope: ");
+  for (const auto& scope : scopes_) {
+    if (!scope.element_name.empty()) {
+      absl::StrAppend(&error_message_, scope.element_name, " > ");
+    }
+  }
+}
+
+size_t BerParser::GetCurrentOffset() {
+  if (scopes_.empty()) {
+    return 0;
+  }
+  return CBS_data(&scopes_.back().data) - start_;
+}
+
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/ber_parser.h b/crypto/default/cms/ber_parser.h
new file mode 100644
index 0000000..d21669b
--- /dev/null
+++ b/crypto/default/cms/ber_parser.h
@@ -0,0 +1,188 @@
+// 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_CRYPTO_DEFAULT_CMS_BER_PARSER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_BER_PARSER_H_
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/strings/string_view.h"
+#include "crypto/default/cms/cms_error_code.h"
+#include "openssl/bytestring.h"
+
+namespace credentio_cms {
+
+// Zero copy ASN.1 parser supporting the minimum amount of BER encoding needed
+// to work with the S/MIME clients targeted.
+//
+// This parser reads the elements in the order they appear, when a constructed
+// element is read, the next element will be read from its value.
+//
+// Example if the data contains this:
+// SEQUENCE
+//   INTEGER 1
+//   OCTECT_STRING 'foo'
+// SET
+// ...
+// The elements read will be in order: SEQUENCE, INTEGER, OCTET_STRING,
+// SET.
+//
+// Error handling: the parser keeps track of the first error. Once an error
+// is encountered all the functions will effectively be no-op.
+// This allows the caller to postpone error checking until the end of the
+// parsing and makes the code easier to read.
+class BerParser {
+ public:
+  // The data must be valid for the life of the BerParser object.
+  BerParser(const uint8_t* data, size_t length);
+
+  // Returns true if the parsing is successful, or false if there was an error.
+  // Errors are sticky and once the parser encounters an error (or SetError is
+  // called).
+  bool ok() const;
+
+  // Returns the current status.
+  ErrorCode status() const;
+
+  // Returns the text for the error, if any.
+  std::string error_message() const;
+
+  // Checks that the next element has the expected tag and read it.
+  // If the element is constructed the parser will read from its value until
+  // EndConstructedElement is called.
+  // If the next element does not match an error will be set and ok() will
+  // always return false.
+  void GetTag(unsigned expected_tag);
+
+  // If the next element has a tag equals to expected_tag this function behaves
+  // like GetTag, otherwise it is a no-op.
+  // Returns true if the expected_tag was found and read.
+  bool GetOptionalTag(unsigned expected_tag);
+
+  // If the next element has a tag equal to expected_tag, read it and make
+  // 'out' point to the element value, otherwise set an error condition and
+  // clear the 'out' parameter.
+  // If the element is constructed the parser will read from its value until
+  // EndConstructedElement is called.
+  //
+  // Restriction: the element must have a definite length.
+  void GetElementData(unsigned expected_tag, CBS* out);
+
+  // If the next element has a tag equal to 'expected_tag' this function behaves
+  // like GetElementData, otherwise it is a no-op
+  bool GetOptionalElementData(unsigned expected_tag, CBS* out);
+
+  // Indicate that parsing is done for the current constructed element, check
+  // that there is no data left in it and keep reading from its siblings.
+  // If the current constructed element had an indefinite length, also check for
+  // the end of content marker.
+  void EndConstructed();
+
+  // Read the next element regardless of its tag and put the header and data in
+  // the 'out' parameter. Its contents will be skipped even if it is a
+  // constructed element (so no need to call EndConstructedElement).
+  // Restriction: the element must have a definite length.
+  void GetAnyElementAndSkipChildren(CBS* out);
+
+  // Returns true if there is no error and the next element matches the
+  // expected tag.
+  bool Peek(unsigned expected_tag) const;
+
+  // Returns the offset of the current tag byte relative to the start of the
+  // 'data' passed in the constructor.
+  size_t GetCurrentOffset();
+
+  // Returns true if:
+  // - The next element has the NULL type (the null will be consumed).
+  // - The current parsing scope is empty (reached the end of the data).
+  // - The next element is the End Of Content marker and the scope was an
+  //   indefinite length element.
+  // - There was an earlier parsing error (to indicate that nothing more
+  //   will be read).
+  bool IsNullOrEmpty();
+
+  // Checks that the next element has an INTEGER tag and its value fits in an
+  // int. The element is consumed.
+  // If there is an error return zero.
+  int GetInt();
+
+  // The text passed in will be added to the error message if an error happens
+  // in the current parsing context.
+  // This has no effect on parsing but allows error messages to be more
+  // informative.
+  void SetElementName(absl::string_view text);
+
+  // If the parser is not already in an error state, this will set the internal
+  // status and build an error message using 'text' and appending the byte
+  // offset and scope chain.
+  // If 'status' is OK this function is no-op (i.e. it doesn't clear existing
+  // errors).
+  void SetError(ErrorCode status, absl::string_view text);
+
+ private:
+  struct Scope {
+    Scope(const CBS& data, bool indefinite)
+        : data(data), indefinite(indefinite) {}
+    // If this is the first stack level it points to the complete data.
+    // If we are currently parsing a constructed element value, if indefinite_
+    // is false it contains the element value, otherwise it contains all the
+    // data that is left to parse.
+    CBS data;
+    // Indicates if 'data' belongs to a definite or indefinite length
+    // constructed element.
+    bool indefinite = false;
+    // Used to provide more useful error messages.
+    std::string element_name;
+  };
+
+  // Sets out data to nullptr and its length to 0.
+  // Returns false if an error occurred.
+  bool IsValid(CBS* out);
+
+  // Returns true if the next two bytes are 0,0 (ASN.1 End Of Content), does not
+  // consume the EOC marker.
+  bool IsEndOfContent();
+
+  // Reads the next element.
+  // - out_tag will contain the tag value read.
+  // - raw_tag: if it is false and the element is constructed, a new scope will
+  //   be pushed and the next tags will be read from its value. If it is true
+  //   the element value will be skipped by the parser.
+  // - out_value will be set to the element data if it is not null.
+  // - out_value will be set to the element header and data if it is not null.
+  void GetTagInternal(unsigned* out_tag, CBS* out_value,
+                      CBS* out_header_and_value, bool raw_tag);
+
+  // Calls SetError if tag != expected_tag and clears out if it is not null.
+  void CompareTag(unsigned tag, unsigned expected_tag, CBS* out);
+
+  std::string error_message_;
+  ErrorCode status_ = ErrorCode::OK;
+  // Contains the start of the data being parsed, only used to show offsets in
+  // error messages.
+  const uint8_t* start_;
+  // Each time a constructed tag is entered a new scope is pushed. If the length
+  // is indefinite the data contains all the remaining data.
+  // Each time CloseTag is called, a scope is popped, if the length was
+  // indefinite the next read will start right after the scope just popped.
+  std::vector<Scope> scopes_;
+};
+
+}  // namespace credentio_cms
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_BER_PARSER_H_
diff --git a/crypto/default/cms/ber_parser_test.cc b/crypto/default/cms/ber_parser_test.cc
new file mode 100644
index 0000000..d1f1afd
--- /dev/null
+++ b/crypto/default/cms/ber_parser_test.cc
@@ -0,0 +1,828 @@
+// 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 "crypto/default/cms/ber_parser.h"
+
+#include <cstring>
+#include <limits>
+#include <string>
+
+#include "crypto/default/cms/cms_error_code.h"
+#include "gtest/gtest.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+
+namespace credentio_cms {
+namespace {
+
+TEST(BerParser, NoData) {
+  BerParser parser(nullptr, 0);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+  EXPECT_EQ(ErrorCode::OK, parser.status());
+  EXPECT_EQ("", parser.error_message());
+  EXPECT_FALSE(parser.GetOptionalTag(0));
+  EXPECT_TRUE(parser.ok());
+  // Trying to read a tag should set an error.
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+}
+
+TEST(BerParser, GetTagNonConstructedOk) {
+  uint8_t data[] = {// BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    // OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_EQ(ErrorCode::OK, parser.status());
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_EQ(ErrorCode::OK, parser.status());
+  // Reading past the end
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+
+  // Reading the wrong tag.
+  BerParser parser2(data, sizeof(data));
+  parser2.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+}
+
+TEST(BerParser, GetTagDefiniteLengthConstructedOk) {
+  uint8_t data[] = {// SEQUENCE {
+                    0x30, 8,
+                    //   BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    // INTEGER { 0x55 }
+                    0x2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  EXPECT_TRUE(parser.ok());
+  // We should now read inside the sequence.
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_TRUE(parser.ok());
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+  // Trying to read past the end of the sequence without calling CloseTag should
+  // fail.
+  parser.GetTag(CBS_ASN1_INTEGER);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+}
+
+TEST(BerParser, GetTagIndefiniteLengthConstructedOk) {
+  uint8_t data[] = {// SEQUENCE (indefinite) {
+                    0x30, 0x80,
+                    //   BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    0, 0,
+                    // INTEGER { 0x55 }
+                    0x2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  EXPECT_TRUE(parser.ok());
+  // We should now read inside the sequence.
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_TRUE(parser.ok());
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+  // Trying to read past the end of the sequence without calling CloseTag should
+  // fail.
+  parser.GetTag(CBS_ASN1_INTEGER);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+}
+
+TEST(BerParser, GetTagNoDataInElement) {
+  // Making sure that an element with with no data is not seen as having
+  // indefinite length.
+  uint8_t data[] = {// NULL {}
+                    5, 0,
+                    // INTEGER { 0x55 }
+                    2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(5);
+  EXPECT_TRUE(parser.ok());
+  parser.GetTag(CBS_ASN1_INTEGER);
+  EXPECT_TRUE(parser.ok());
+}
+
+TEST(BerParser, GetTagBrokenEncoding) {
+  uint8_t data[] = {1, 2, 0xFF};  // BOOLEAN { TRUE }
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+}
+
+TEST(BerParser, GetTagMalformedData) {
+  uint8_t data[] = {1};  // BOOLEAN { missing value / error }
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+}
+
+TEST(BerParser, GetTagReadAfterError) {
+  uint8_t data[] = {// BOOLEAN { TRUE }
+                    1, 1, 0xFF,
+                    // OCTET STRING { "\x11\x22\x33" }
+                    4, 3, 0x11, 0x22, 0x33};
+  BerParser parser(data, sizeof(data));
+  // Expecting the wrong tag.
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+  // Now that the parser is in an error state reading the correct tag
+  // should leave it in the error state.
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::ASN1_PARSING_ERROR, parser.status());
+}
+
+TEST(BerParser, GetOptionalTag) {
+  uint8_t data[] = {// BOOLEAN { TRUE }
+                    1, 1, 0xFF,
+                    // OCTET STRING { "\x11\x22\x33" }
+                    4, 3, 0x11, 0x22, 0x33};
+  BerParser parser(data, sizeof(data));
+  EXPECT_FALSE(parser.GetOptionalTag(CBS_ASN1_OCTETSTRING));
+  EXPECT_TRUE(parser.ok());
+  EXPECT_TRUE(parser.GetOptionalTag(CBS_ASN1_BOOLEAN));
+  EXPECT_TRUE(parser.ok());
+  EXPECT_FALSE(parser.GetOptionalTag(CBS_ASN1_BOOLEAN));
+  EXPECT_TRUE(parser.GetOptionalTag(CBS_ASN1_OCTETSTRING));
+  // Reading and optional tag at the end doesn't set an error.
+  EXPECT_FALSE(parser.GetOptionalTag(CBS_ASN1_OCTETSTRING));
+  EXPECT_TRUE(parser.ok());
+}
+
+TEST(BerParser, GetElementDataOk) {
+  uint8_t data[] = {// BOOLEAN { TRUE }
+                    1, 1, 0xFF,
+                    // OCTET STRING { "\x11\x22\x33" }
+                    4, 3, 0x11, 0x22, 0x33};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);  // harmless dummy values.
+
+  parser.GetElementData(CBS_ASN1_BOOLEAN, &out);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_EQ(1, CBS_len(&out));
+  EXPECT_EQ(data + 2, CBS_data(&out));
+  parser.GetElementData(CBS_ASN1_OCTETSTRING, &out);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_EQ(3, CBS_len(&out));
+  EXPECT_EQ(data + 5, CBS_data(&out));
+  // Read past the end, it should set an error and clear 'out'.
+  parser.GetElementData(CBS_ASN1_OCTETSTRING, &out);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+}
+
+TEST(BerParser, GetElementDataReadAfterError) {
+  uint8_t data[] = {// BOOLEAN { TRUE }
+                    1, 1, 0xFF,
+                    // OCTET STRING { "\x11\x22\x33" }
+                    4, 3, 0x11, 0x22, 0x33};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);  // harmless dummy values.
+
+  // Expect the wrong tag.
+  parser.GetElementData(CBS_ASN1_OCTETSTRING, &out);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+  // Now all reads will fail and 'out' will be empty.
+  parser.GetElementData(CBS_ASN1_BOOLEAN, &out);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+}
+
+TEST(BerParser, GetElementDataConstructedDefiniteTag) {
+  uint8_t data[] = {// SEQUENCE {
+                    0x30, 8,
+                    //   BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    // INTEGER { 0x55 }
+                    0x2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+
+  parser.GetElementData(CBS_ASN1_SEQUENCE, &out);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_EQ(8, CBS_len(&out));
+  EXPECT_EQ(data + 2, CBS_data(&out));
+
+  parser.GetElementData(CBS_ASN1_BOOLEAN, &out);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_EQ(1, CBS_len(&out));
+  EXPECT_EQ(data + 4, CBS_data(&out));
+  EXPECT_EQ(0, memcmp(CBS_data(&out), "\xFF", 1));
+
+  parser.GetElementData(CBS_ASN1_OCTETSTRING, &out);
+  EXPECT_TRUE(parser.ok());
+  EXPECT_EQ(3, CBS_len(&out));
+  EXPECT_EQ(data + 7, CBS_data(&out));
+  EXPECT_EQ(0, memcmp(CBS_data(&out), "\x11\x22\x33", 3));
+
+  // Reading past the end of the sequence.
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+  parser.GetElementData(CBS_ASN1_INTEGER, &out);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+}
+
+TEST(BerParser, GetElementDataConstructedIndefiniteLengthTag) {
+  uint8_t data[] = {// SEQUENCE (indefinite) {
+                    0x30, 0x80,
+                    //   BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    0, 0,
+                    // INTEGER { 0x55 }
+                    0x2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  // We don't support reading element data for indefinite length elements.
+  parser.GetElementData(CBS_ASN1_SEQUENCE, &out);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+}
+
+TEST(BerParse, GetElementDataNullPointer) {
+  uint8_t data[] = {1, 1, 0xFF};  // BOOLEAN { TRUE }
+  BerParser parser(data, sizeof(data));
+  parser.GetElementData(CBS_ASN1_BOOLEAN, nullptr);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::INVALID_PARAMETER, parser.status());
+}
+
+TEST(BerParser, GetElementDataMalformedData) {
+  uint8_t data[] = {1};  // BOOLEAN { missing value / error }
+  BerParser parser(data, sizeof(data));
+  parser.GetElementData(CBS_ASN1_BOOLEAN, nullptr);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::INVALID_PARAMETER, parser.status());
+}
+
+TEST(BerParser, GetOptionalElementData) {
+  uint8_t data[] = {1, 1, 0xFF};  // BOOLEAN { TRUE }
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  EXPECT_FALSE(parser.GetOptionalElementData(CBS_ASN1_OCTETSTRING, &out));
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+  // Not finding the optional tag should not set an error.
+  EXPECT_TRUE(parser.ok());
+
+  EXPECT_TRUE(parser.GetOptionalElementData(CBS_ASN1_BOOLEAN, &out));
+  EXPECT_EQ(1, CBS_len(&out));
+  EXPECT_EQ(0, memcmp(CBS_data(&out), "\xff", 1));
+  EXPECT_TRUE(parser.ok());
+  // Make sure that reading after an error clears 'out'
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_FALSE(parser.ok());
+  CBS_init(&out, data, 5);
+  EXPECT_FALSE(parser.GetOptionalElementData(CBS_ASN1_BOOLEAN, &out));
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+}
+
+TEST(BerParse, GetOptionalElementDataNullPointer) {
+  uint8_t data[] = {1, 1, 0xFF};  // BOOLEAN { TRUE }
+  BerParser parser(data, sizeof(data));
+  parser.GetOptionalElementData(CBS_ASN1_BOOLEAN, nullptr);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(ErrorCode::INVALID_PARAMETER, parser.status());
+}
+
+TEST(BerParser, GetAnyElementAndSkipChildrenConstructed) {
+  uint8_t data[] = {// SET {
+                    0x31, 8,
+                    //   BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    // INTEGER { 0x55 }
+                    0x2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  parser.GetAnyElementAndSkipChildren(&out);
+  EXPECT_EQ(10, CBS_len(&out));
+  EXPECT_EQ(data, CBS_data(&out));
+  EXPECT_TRUE(parser.ok());
+  // The next read should be the INTEGER since the contents of the set are not
+  // parsed.
+  parser.GetAnyElementAndSkipChildren(&out);
+  EXPECT_EQ(3, CBS_len(&out));
+  EXPECT_EQ(0, memcmp(CBS_data(&out), "\x02\x01\x55", 1));
+  EXPECT_TRUE(parser.ok());
+  // And it should fail when reading past the end.
+  parser.GetAnyElementAndSkipChildren(&out);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+}
+
+TEST(BerParser, GetAnyElementAndSkipChildrenConstructedIndefiniteLength) {
+  uint8_t data[] = {// SEQUENCE (indefinite) {
+                    0x30, 0x80,
+                    //   BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    0, 0,
+                    // INTEGER { 0x55 }
+                    0x2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  parser.GetAnyElementAndSkipChildren(&out);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+}
+
+TEST(BerParser, GetAnyElementAndSkipChildrenReadAfterError) {
+  uint8_t data[] = {4, 3, 0x11, 0x22, 0x33};  // OCTET STRING { "\x11\x22\x33" }
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  parser.GetAnyElementAndSkipChildren(&out);
+  EXPECT_EQ(sizeof(data), CBS_len(&out));
+  EXPECT_EQ(data, CBS_data(&out));
+  EXPECT_TRUE(parser.ok());
+
+  BerParser parser2(data, sizeof(data));
+  parser2.GetTag(CBS_ASN1_SEQUENCE);
+  EXPECT_FALSE(parser2.ok());
+  // The read should fail and out be initialized.
+  parser2.GetAnyElementAndSkipChildren(&out);
+  EXPECT_FALSE(parser2.ok());
+  EXPECT_EQ(0, CBS_len(&out));
+  EXPECT_EQ(nullptr, CBS_data(&out));
+}
+
+TEST(BerParser, Peek) {
+  uint8_t data[] = {4, 3, 0x11, 0x22, 0x33};  // OCTET STRING { "\x11\x22\x33" }
+  BerParser parser(data, sizeof(data));
+  EXPECT_TRUE(parser.Peek(CBS_ASN1_OCTETSTRING));
+  // Peek doesn't read so it can be repeated.
+  EXPECT_TRUE(parser.Peek(CBS_ASN1_OCTETSTRING));
+  EXPECT_FALSE(parser.Peek(CBS_ASN1_INTEGER));
+  // Create an error condition.
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_FALSE(parser.Peek(CBS_ASN1_OCTETSTRING));
+}
+
+TEST(BerParser, IsNullOrEmpty) {
+  uint8_t data[] = {4, 3, 0x11, 0x22, 0x33};  // OCTET STRING { "\x11\x22\x33" }
+  BerParser parser(data, sizeof(data));
+  EXPECT_FALSE(parser.IsNullOrEmpty());
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+
+  BerParser parser2(nullptr, 0);
+  EXPECT_TRUE(parser2.IsNullOrEmpty());
+}
+
+TEST(BerParser, IsNullOrEmptyAfterError) {
+  uint8_t data[] = {4, 3, 0x11, 0x22, 0x33};  // OCTET STRING { "\x11\x22\x33" }
+  BerParser parser(data, sizeof(data));
+  EXPECT_FALSE(parser.IsNullOrEmpty());
+  parser.GetTag(CBS_ASN1_INTEGER);
+  EXPECT_FALSE(parser.ok());
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+}
+
+TEST(BerParser, IsNullOrEmptyDefiniteLengthConstructed) {
+  uint8_t data[] = {// SEQUENCE {
+                    0x30, 8,
+                    //   BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    // INTEGER { 0x55 }
+                    0x2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  EXPECT_FALSE(parser.IsNullOrEmpty());
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_FALSE(parser.IsNullOrEmpty());
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+  EXPECT_TRUE(parser.ok());
+}
+
+TEST(BerParser, IsNullOrEmptyConstructedWithNull) {
+  uint8_t data[] = {// SET {
+                    0x31, 5,
+                    //   BOOLEAN { TRUE }
+                    1, 1, 0xFF,
+                    //   NULL {}
+                    5, 0,
+                    // }
+                    // INTEGER { 0x55 }
+                    2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  parser.GetTag(CBS_ASN1_SET);
+  EXPECT_FALSE(parser.IsNullOrEmpty());
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_TRUE(parser.Peek(5 /* NULL */));
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+  // Make sure that IsNullOrEmpty consumed the NULL.
+  EXPECT_FALSE(parser.Peek(5 /* NULL */));
+  EXPECT_TRUE(parser.ok());
+}
+
+TEST(BerParser, IsNullOrEmptyConstructedWithBrokenNull) {
+  uint8_t data[] = {// SET {
+                    0x31, 5,
+                    //   BOOLEAN { TRUE }
+                    1, 1, 0xFF,
+                    //   NULL with invalid length
+                    5, 1,
+                    // }
+                    // INTEGER { 0x55 }
+                    2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  parser.GetTag(CBS_ASN1_SET);
+  EXPECT_FALSE(parser.IsNullOrEmpty());
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_TRUE(parser.Peek(5 /* NULL */));
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, IsNullOrEmptyIndefiniteLengthConstructed) {
+  uint8_t data[] = {// SEQUENCE (indefinite) {
+                    0x30, 0x80,
+                    //   BOOLEAN { TRUE }
+                    0x1, 1, 0xFF,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    0x4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    0, 0,
+                    // INTEGER { 0x55 }
+                    0x2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  CBS out;
+  CBS_init(&out, data, 5);
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  EXPECT_FALSE(parser.IsNullOrEmpty());
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_FALSE(parser.IsNullOrEmpty());
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_TRUE(parser.IsNullOrEmpty());
+  EXPECT_TRUE(parser.ok());
+}
+
+TEST(BerParser, GetInt) {
+  uint8_t data[] = {2, 1, 0x55};  // INTEGER { 0x55 }
+  BerParser parser(data, sizeof(data));
+  EXPECT_EQ(0x55, parser.GetInt());
+  EXPECT_TRUE(parser.ok());
+  // Reading after an error should just clear the value passed in.
+  EXPECT_EQ(0, parser.GetInt());
+  EXPECT_FALSE(parser.ok());
+  EXPECT_EQ(0, parser.GetInt());
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, GetIntWrongTag) {
+  uint8_t data[] = {1, 1, 0x55};  // BOOLEAN { TRUE }
+  BerParser parser(data, sizeof(data));
+  EXPECT_EQ(0, parser.GetInt());
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, GetIntNegative) {
+  uint8_t data[100] = {2 /* INTEGER */};
+  static_assert(sizeof(int) + 2 < sizeof(data), "The int size is too large");
+  data[1] = sizeof(int);
+  for (int i = 0; i < sizeof(int); ++i) {
+    data[i + 2] = 0xFF;
+  }
+  BerParser parser(data, sizeof(data));
+  EXPECT_EQ(0, parser.GetInt());
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, GetIntMaxValue) {
+  uint8_t data[100] = {2 /* INTEGER */};
+  static_assert(sizeof(int) + 2 < sizeof(data), "The int size is too large");
+  data[1] = sizeof(int);
+  for (int i = 0; i < sizeof(int); ++i) {
+    data[i + 2] = 0xFF;
+  }
+  data[2] = 0x7F;
+  BerParser parser(data, sizeof(data));
+  EXPECT_EQ(std::numeric_limits<int>::max(), parser.GetInt());
+  EXPECT_TRUE(parser.ok());
+}
+
+TEST(BerParser, GetIntValueTooLarge) {
+  // The value doesn't fit in an uint64_t.
+  uint8_t data[100] = {2 /* INTEGER */, 9, 1};
+  static_assert(sizeof(uint64_t) == 8, "Unexpected uint64_t size.");
+  BerParser parser(data, sizeof(data));
+  EXPECT_EQ(0, parser.GetInt());
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, GetIntMaxValuePlusOne) {
+  uint8_t data[100] = {2 /* INTEGER */};
+  static_assert(sizeof(int) + 2 < sizeof(data), "The int size is too large");
+  data[1] = sizeof(int);
+  data[2] = 0x80;
+  BerParser parser(data, sizeof(data));
+  EXPECT_EQ(0, parser.GetInt());
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, EndConstructed) {
+  uint8_t data[] = {
+      // SET {
+      0x31, 5,
+      //   OCTET STRING { "\x11\x22\x33" }
+      4, 3, 0x11, 0x22, 0x33,
+      //   INTEGER { 0x55 }
+      2, 1, 0x55
+      // }
+  };
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SET);
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_FALSE(parser.Peek(CBS_ASN1_INTEGER));
+  parser.EndConstructed();
+  EXPECT_TRUE(parser.ok());
+  EXPECT_TRUE(parser.Peek(CBS_ASN1_INTEGER));
+}
+
+TEST(BerParser, EndConstructedTwoLevels) {
+  uint8_t data[] = {// SEQUENCE {
+                    0x30, 10,
+                    //   SET {
+                    0x31, 5,
+                    //     OCTET STRING { "\x11\x22\x33" }
+                    4, 3, 0x11, 0x22, 0x33,
+                    //   }
+                    //   BOOLEAN { TRUE }
+                    1, 1, 0xFF,
+                    // }
+                    // INTEGER { 0x55 }
+                    2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  parser.GetTag(CBS_ASN1_SET);
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_FALSE(parser.Peek(CBS_ASN1_BOOLEAN));
+  parser.EndConstructed();
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_FALSE(parser.Peek(CBS_ASN1_INTEGER));
+  parser.EndConstructed();
+  parser.GetTag(CBS_ASN1_INTEGER);
+  EXPECT_TRUE(parser.ok());
+}
+
+TEST(BerParser, EndConstructedUnexpectedData) {
+  uint8_t data[] = {// SET {
+                    0x31, 5,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    4, 3, 0x11, 0x22, 0x33,
+                    // }
+                    // INTEGER { 0x55 }
+                    2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SET);
+  parser.EndConstructed();
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, EndConstructedIndefiniteLength) {
+  uint8_t data[] = {// SEQUENCE (indefinite) {
+                    0x30, 0x80,
+                    //   SET (indefinite) {
+                    0x31, 0x80,
+                    //     OCTET STRING { "\x11\x22\x33" }
+                    4, 3, 0x11, 0x22, 0x33,
+                    //   }
+                    0, 0,
+                    //   BOOLEAN { TRUE }
+                    1, 1, 0xFF,
+                    // }
+                    0, 0,
+                    // INTEGER { 0x55 }
+                    2, 1, 0x55};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  parser.GetTag(CBS_ASN1_SET);
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_FALSE(parser.Peek(CBS_ASN1_BOOLEAN));
+  parser.EndConstructed();
+  parser.GetTag(CBS_ASN1_BOOLEAN);
+  EXPECT_FALSE(parser.Peek(CBS_ASN1_INTEGER));
+  parser.EndConstructed();
+  parser.GetTag(CBS_ASN1_INTEGER);
+  EXPECT_TRUE(parser.ok());
+}
+
+TEST(BerParser, EndConstructedIndefiniteLengthUnexpectedData) {
+  uint8_t data[] = {
+      // SET (indefinite) {
+      0x31,
+      0x80,
+      //   OCTET STRING { "\x11\x22\x33" }
+      4,
+      3,
+      0x11,
+      0x22,
+      0x33,
+      //   INTEGER { 0x55 }
+      2,
+      1,
+      0x55,
+      // The SET is missing the EOC.
+  };
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SET);
+  parser.EndConstructed();
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, EndConstructedMissingEOC) {
+  uint8_t data[] = {
+      // SET (indefinite) {
+      0x31,
+      0x80,
+      //   OCTET STRING { "\x11\x22\x33" }
+      4,
+      3,
+      0x11,
+      0x22,
+      0x33,
+      // The SET is missing the EOC.
+  };
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SET);
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  parser.EndConstructed();
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, EndConstructedIncompleteEOC) {
+  uint8_t data[] = {// SET (indefinite) {
+                    0x31, 0x80,
+                    //   OCTET STRING { "\x11\x22\x33" }
+                    4, 3, 0x11, 0x22, 0x33,
+                    // Incomplete EOC for the SET.
+                    0};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SET);
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  parser.EndConstructed();
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, EndConstructedIndefiniteLengthEOCInNextTag) {
+  uint8_t data[] = {
+      // SET {
+      0x31, 7,
+      //   SET (indefinite) {
+      0x31, 0x80,
+      //     OCTET STRING { "\x11\x22\x33" }
+      4, 3, 0x11, 0x22, 0x33,
+      // This is the EOC for the inner set, but it is past the end of the outer
+      // one, which is invalid.
+      0, 0};
+  BerParser parser(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SET);
+  parser.GetTag(CBS_ASN1_SET);
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  EXPECT_TRUE(parser.ok());
+  parser.EndConstructed();
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, EndConstructedTooManyCalls) {
+  BerParser parser(nullptr, 0);
+  EXPECT_TRUE(parser.ok());
+  parser.EndConstructed();
+  EXPECT_FALSE(parser.ok());
+
+  // This is the following structure, missing the SET's EOC:
+  //
+  //   SET (indefinite) { OCTET STRING { "\x11" }
+  uint8_t data[] = {0x31, 0x80, 4, 1, 0x11};
+  BerParser parser2(data, sizeof(data));
+  parser.GetTag(CBS_ASN1_SET);
+  parser.GetTag(CBS_ASN1_OCTETSTRING);
+  parser.EndConstructed();
+  EXPECT_FALSE(parser.ok());
+  parser.EndConstructed();
+  EXPECT_FALSE(parser.ok());
+}
+
+TEST(BerParser, SetError) {
+  BerParser parser(nullptr, 0);
+  EXPECT_TRUE(parser.ok());
+  parser.SetError(ErrorCode::UNSUPPORTED, "foo");
+  EXPECT_EQ(ErrorCode::UNSUPPORTED, parser.status());
+  EXPECT_FALSE(parser.ok());
+  EXPECT_GE(parser.error_message().find("foo"), 0);
+  // Make sure that the error can't be reset.
+  parser.SetError(ErrorCode::OK, "bar");
+  EXPECT_EQ(ErrorCode::UNSUPPORTED, parser.status());
+  EXPECT_FALSE(parser.ok());
+  EXPECT_GE(parser.error_message().find("foo"), 0);
+}
+
+TEST(BerParser, SetElementName) {
+  uint8_t data[] = {// SET (indefinite {
+                    0x31, 0x80,
+                    //   OCTET STRING { \x11 }
+                    4, 1, 0x11,
+                    // }
+                    0, 0};
+  BerParser parser(data, sizeof(data));
+  parser.SetElementName("@1@");
+  parser.GetInt();
+  EXPECT_FALSE(parser.ok());
+  EXPECT_GE(parser.error_message().find("@1@"), 0);
+
+  BerParser parser2(data, sizeof(data));
+  parser2.SetElementName("@1@");
+  parser2.GetTag(CBS_ASN1_SET);
+  parser2.SetElementName("@2@");
+  parser2.GetInt();
+  EXPECT_FALSE(parser2.ok());
+  EXPECT_GE(parser2.error_message().find("@1@"), 0);
+  EXPECT_GE(parser2.error_message().find("@2@"), 0);
+
+  // Check that the names are not in the error string once their context is
+  // poped.
+  BerParser parser3(data, sizeof(data));
+  parser3.SetElementName("@1@");
+  parser3.GetTag(CBS_ASN1_SET);
+  parser3.SetElementName("@2@");
+  parser3.GetTag(CBS_ASN1_OCTETSTRING);
+  parser3.EndConstructed();
+  EXPECT_TRUE(parser3.ok());
+  parser3.GetInt();
+  EXPECT_FALSE(parser2.ok());
+  EXPECT_GE(parser3.error_message().find("@1@"), 0);
+  EXPECT_EQ(std::string::npos, parser3.error_message().find("@2@"));
+}
+
+}  // namespace
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/certificates.cc b/crypto/default/cms/certificates.cc
new file mode 100644
index 0000000..ad47c62
--- /dev/null
+++ b/crypto/default/cms/certificates.cc
@@ -0,0 +1,134 @@
+// 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 "crypto/default/cms/certificates.h"
+
+#include <cstring>
+#include <vector>
+
+#include "crypto/default/cms/cms_parser.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+#include "openssl/obj_mac.h"
+#include "openssl/safestack.h"
+#include "openssl/stack.h"
+#include "openssl/x509.h"
+
+namespace credentio_cms {
+namespace {
+
+std::vector<X509*> MatchByIssuerNameAndSerialNumber(
+    const STACK_OF(X509) * certificates, const ByteString& issuer_name,
+    const ByteString& serial_number) {
+  std::vector<X509*> matches;
+  if (CBS_data(issuer_name.cbs_ptr()) == nullptr ||
+      CBS_data(serial_number.cbs_ptr()) == nullptr ||
+      CBS_len(issuer_name.cbs_ptr()) == 0 ||
+      CBS_len(serial_number.cbs_ptr()) == 0) {
+    return matches;
+  }
+  const unsigned char* p = CBS_data(issuer_name.cbs_ptr());
+  bssl::UniquePtr<X509_NAME> x509_issuer_name(
+      d2i_X509_NAME(nullptr, &p, CBS_len(issuer_name.cbs_ptr())));
+  p = CBS_data(serial_number.cbs_ptr());
+  bssl::UniquePtr<ASN1_INTEGER> x509_serial_number(
+      c2i_ASN1_INTEGER(nullptr, &p, CBS_len(serial_number.cbs_ptr())));
+  if (x509_issuer_name == nullptr || x509_serial_number == nullptr) {
+    return matches;
+  }
+  for (int i = 0; i < sk_X509_num(certificates); ++i) {
+    X509* cert = sk_X509_value(certificates, i);
+    if (cert == nullptr) {
+      continue;
+    }
+    const ASN1_INTEGER* certificate_serial = X509_get0_serialNumber(cert);
+    if (certificate_serial == nullptr ||
+        ASN1_INTEGER_cmp(x509_serial_number.get(), certificate_serial) != 0) {
+      continue;
+    }
+    if (X509_name_cmp(x509_issuer_name.get(), X509_get_issuer_name(cert)) ==
+        0) {
+      matches.push_back(cert);
+    }
+  }
+  return matches;
+}
+
+std::vector<X509*> MatchBySubjectKeyId(
+    const STACK_OF(X509) * certificates,
+    const ByteString& subject_key_identifier) {
+  std::vector<X509*> matches;
+  for (int i = 0; i < sk_X509_num(certificates); ++i) {
+    X509* cert = sk_X509_value(certificates, i);
+    if (cert == nullptr) {
+      continue;
+    }
+    int index = X509_get_ext_by_NID(cert, NID_subject_key_identifier, -1);
+    if (index < 0) {
+      continue;
+    }
+    const X509_EXTENSION* ext = X509_get_ext(cert, index);
+    if (ext == nullptr) {
+      continue;
+    }
+    const ASN1_OCTET_STRING* value = X509_EXTENSION_get_data(ext);
+    // https://tools.ietf.org/html/rfc5280#section-4.2.1.2
+    CBS extension;
+    CBS_init(&extension, ASN1_STRING_get0_data(value),
+             ASN1_STRING_length(value));
+    CBS ext_ski;
+    if (CBS_get_asn1(&extension, &ext_ski, CBS_ASN1_OCTETSTRING) == 1 &&
+        CBS_len(&ext_ski) == CBS_len(subject_key_identifier.cbs_ptr()) &&
+        memcmp(CBS_data(subject_key_identifier.cbs_ptr()), CBS_data(&ext_ski),
+               CBS_len(&ext_ski)) == 0) {
+      matches.push_back(cert);
+    }
+  }
+  return matches;
+}
+
+}  // namespace
+
+STACK_OF(X509) * GetAllCertificates(const Content& cms_content) {
+  bssl::UniquePtr<STACK_OF(X509)> stack(sk_X509_new_null());
+  for (const auto& certificate : cms_content.certificates) {
+    // Return null if there is an error parsing any certificate instead of
+    // returning partial results.
+    const unsigned char* udata = CBS_data(certificate.cbs_ptr());
+    if (udata == nullptr || CBS_len(certificate.cbs_ptr()) == 0) {
+      return nullptr;
+    }
+    X509* cert =
+        d2i_X509(nullptr /* unused */, &udata, CBS_len(certificate.cbs_ptr()));
+    if (cert == nullptr) {
+      return nullptr;
+    }
+    sk_X509_push(stack.get(), cert);
+  }
+  return stack.release();
+}
+
+std::vector<X509*> GetSignerCertificates(const STACK_OF(X509) * certificates,
+                                         const SignerInfo& signer) {
+  if (CBS_len(signer.subject_key_identifier.cbs_ptr()) > 0) {
+    return MatchBySubjectKeyId(certificates, signer.subject_key_identifier);
+  } else {
+    return MatchByIssuerNameAndSerialNumber(certificates, signer.issuer_name,
+                                            signer.serial_number);
+  }
+}
+
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/certificates.h b/crypto/default/cms/certificates.h
new file mode 100644
index 0000000..0c0a440
--- /dev/null
+++ b/crypto/default/cms/certificates.h
@@ -0,0 +1,46 @@
+// 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_CRYPTO_DEFAULT_CMS_CERTIFICATES_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CERTIFICATES_H_
+
+#include <vector>
+
+#include "crypto/default/cms/cms_parser.h"
+#include "openssl/ossl_typ.h"
+#include "openssl/safestack.h"
+#include "openssl/x509.h"
+
+namespace credentio_cms {
+// Parses the certificates returned in a CmsContent.
+// The caller is responsible for deleting the stack returned using
+// sk_X509_pop_free.
+// Returns null if there is an error when parsing the certificates.
+STACK_OF(X509) * GetAllCertificates(const Content& cms_content);
+
+// Return all the embedded certificates matching the signer issuer name and
+// serial number.
+// The certificates are not validated so it is up to the caller to check their
+// trustworthiness, if they match the sender's email address, etc.
+// -  Returns the certificates matching the issuer and serial number or subject
+//    key id mentioned in 'signer'.
+// -  The X509 pointers returned are only valid as long as the certificates
+//    stack is valid. The caller should not free them.
+std::vector<X509*> GetSignerCertificates(const STACK_OF(X509) * certificates,
+                                         const SignerInfo& signer);
+
+}  // namespace credentio_cms
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CERTIFICATES_H_
diff --git a/crypto/default/cms/certificates_test.cc b/crypto/default/cms/certificates_test.cc
new file mode 100644
index 0000000..9332f8d
--- /dev/null
+++ b/crypto/default/cms/certificates_test.cc
@@ -0,0 +1,178 @@
+// 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 "crypto/default/cms/certificates.h"
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/log/die_if_null.h"
+#include "absl/strings/escaping.h"
+#include "crypto/default/cms/cms_error_code.h"
+#include "crypto/default/cms/cms_parser.h"
+#include "crypto/default/cms/cms_test_helper.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/base.h"
+#include "openssl/bio.h"
+#include "openssl/bytestring.h"
+#include "openssl/safestack.h"
+#include "openssl/stack.h"
+#include "openssl/x509.h"
+
+using testing::ElementsAre;
+
+namespace credentio_cms {
+namespace {
+
+std::string X509ToDer(X509* x509) {
+  const int len = i2d_X509(x509, nullptr /* buffer */);
+  CHECK_GT(len, 0);
+  std::unique_ptr<char[]> data(new char[len]);
+  unsigned char* dataptr = reinterpret_cast<unsigned char*>(data.get());
+  const int len2 = i2d_X509(x509, &dataptr);
+  CHECK_EQ(len, len2);
+  std::string der_output;
+  der_output.assign(data.get(), len);
+  return der_output;
+}
+
+ByteString ByteStringFromString(const std::string& value) {
+  ByteString bs;
+  CBS_init(bs.cbs_ptr(), reinterpret_cast<const unsigned char*>(value.data()),
+           value.length());
+  return bs;
+}
+
+class CertificateTest : public testing::Test {
+ protected:
+  void SetUp() override {
+    user1_rsa_.reset(
+        ABSL_DIE_IF_NULL(PemToCertificate(GetFileContents("user1_both.pem"))));
+    user1_ecc_.reset(
+        ABSL_DIE_IF_NULL(PemToCertificate(GetFileContents("user1_ecc.pem"))));
+    user2_rsa_.reset(
+        ABSL_DIE_IF_NULL(PemToCertificate(GetFileContents("user2_both.pem"))));
+    user1_rsa_der_ = X509ToDer(user1_rsa_.get());
+    user1_ecc_der_ = X509ToDer(user1_ecc_.get());
+    user2_rsa_der_ = X509ToDer(user2_rsa_.get());
+    content_.certificates.push_back(ByteStringFromString(user1_rsa_der_));
+    content_.certificates.push_back(ByteStringFromString(user1_ecc_der_));
+    content_.certificates.push_back(ByteStringFromString(user2_rsa_der_));
+
+    test_stack_ = sk_X509_new_null();
+    sk_X509_push(test_stack_, user1_rsa_.get());
+    sk_X509_push(test_stack_, user1_ecc_.get());
+    sk_X509_push(test_stack_, user2_rsa_.get());
+  }
+
+  void TearDown() override {
+    if (test_stack_ != nullptr) {
+      sk_X509_free(test_stack_);
+    }
+  }
+
+  STACK_OF(X509) * test_stack_ = nullptr;
+  Content content_;
+  bssl::UniquePtr<X509> user1_rsa_;
+  bssl::UniquePtr<X509> user1_ecc_;
+  bssl::UniquePtr<X509> user2_rsa_;
+  std::string user1_rsa_der_;
+  std::string user1_ecc_der_;
+  std::string user2_rsa_der_;
+};
+
+TEST_F(CertificateTest, GetAllCertificates) {
+  bssl::UniquePtr<STACK_OF(X509)> stack(GetAllCertificates(content_));
+  ASSERT_EQ(3, sk_X509_num(stack.get()));
+  EXPECT_EQ(0, X509_cmp(sk_X509_value(stack.get(), 0), user1_rsa_.get()));
+  EXPECT_EQ(0, X509_cmp(sk_X509_value(stack.get(), 1), user1_ecc_.get()));
+  EXPECT_EQ(0, X509_cmp(sk_X509_value(stack.get(), 2), user2_rsa_.get()));
+}
+
+TEST_F(CertificateTest, GetByName) {
+  X509_NAME* issuer = X509_get_issuer_name(user1_rsa_.get());
+  const int len = i2d_X509_NAME(issuer, nullptr /* buffer */);
+  CHECK_GT(len, 0);
+  std::unique_ptr<char[]> data(new char[len]);
+  unsigned char* dataptr = reinterpret_cast<unsigned char*>(data.get());
+  const int len2 = i2d_X509_NAME(issuer, &dataptr);
+  CHECK_EQ(len, len2);
+  std::string name_der;
+  name_der.assign(data.get(), len);
+  SignerInfo signer_info;
+  signer_info.issuer_name = ByteStringFromString(name_der);
+  std::string serial = "\x12\x3b";
+  signer_info.serial_number = ByteStringFromString(serial);
+
+  std::vector<X509*> matching = GetSignerCertificates(test_stack_, signer_info);
+  EXPECT_THAT(matching, ElementsAre(user1_ecc_.get()));
+
+  CHECK(absl::HexStringToBytes("123f", &serial));
+  signer_info.serial_number = ByteStringFromString(serial);
+  matching = GetSignerCertificates(test_stack_, signer_info);
+  EXPECT_THAT(matching, ElementsAre());
+}
+
+TEST_F(CertificateTest, GetBySki) {
+  SignerInfo signer_info;
+  std::string ski;
+  CHECK(
+      absl::HexStringToBytes("9B6EAE67945D61FB736371448CE0F2ECBA43CBA0", &ski));
+  signer_info.subject_key_identifier = ByteStringFromString(ski);
+  std::vector<X509*> matching = GetSignerCertificates(test_stack_, signer_info);
+  EXPECT_THAT(matching, ElementsAre(user1_ecc_.get()));
+}
+
+TEST_F(CertificateTest, RFCSkiExample) {
+  // The RFC 4134 4.7 example uses the Subject Key Id to match the signer's
+  // certificate.
+  std::string cms = GetCms(GetFileContents("rfc4134-4.7.msg"));
+  Content contents;
+  std::string error_message;
+  ASSERT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &contents, &error_message))
+      << error_message;
+  bssl::UniquePtr<STACK_OF(X509)> certs(GetAllCertificates(contents));
+  ASSERT_EQ(1, sk_X509_num(certs.get()));
+  ASSERT_EQ(1, contents.signers.size());
+
+  std::vector<X509*> matching =
+      GetSignerCertificates(certs.get(), contents.signers[0]);
+  EXPECT_THAT(matching, ElementsAre(sk_X509_value(certs.get(), 0)));
+}
+
+TEST_F(CertificateTest, GetAllCertificatesWithEmptyCert) {
+  // Zero out the 2nd certificate in the stack.
+  CBS_init(content_.certificates[1].cbs_ptr(), nullptr, 0);
+  bssl::UniquePtr<STACK_OF(X509)> stack(GetAllCertificates(content_));
+  ASSERT_TRUE(stack == nullptr);
+}
+
+TEST_F(CertificateTest, GetAllCertificatesWithBrokenCert) {
+  // A valid X509 certificate should start with a 0x30 (Constructed Sequence)
+  // tag.
+  uint8_t kBrokenCert[] = {0xFF};
+  // Make the second certificate in the stack broken.
+  CBS_init(content_.certificates[1].cbs_ptr(), kBrokenCert,
+           sizeof(kBrokenCert));
+  bssl::UniquePtr<STACK_OF(X509)> stack(GetAllCertificates(content_));
+  ASSERT_TRUE(stack == nullptr);
+}
+
+}  // namespace
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/cms_error_code.h b/crypto/default/cms/cms_error_code.h
new file mode 100644
index 0000000..b975d3e
--- /dev/null
+++ b/crypto/default/cms/cms_error_code.h
@@ -0,0 +1,35 @@
+// 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_CRYPTO_DEFAULT_CMS_CMS_ERROR_CODE_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_ERROR_CODE_H_
+
+namespace credentio_cms {
+
+enum class ErrorCode {
+  OK = 0,
+  // The ASN.1 encoding is not DER or the limited BER subset supported.
+  ASN1_PARSING_ERROR = 1,
+  // This means that the S/MIME part contains an unsupported encoding or
+  // feature.
+  UNSUPPORTED = 2,
+  // Set when invalid parameters are passed to the API, for example an
+  // unexpected null pointer.
+  INVALID_PARAMETER = 3,
+};
+
+}  // namespace credentio_cms
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_ERROR_CODE_H_
diff --git a/crypto/default/cms/cms_parser.cc b/crypto/default/cms/cms_parser.cc
new file mode 100644
index 0000000..0c6ee9f
--- /dev/null
+++ b/crypto/default/cms/cms_parser.cc
@@ -0,0 +1,538 @@
+// 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 "crypto/default/cms/cms_parser.h"
+
+#include <stdint.h>
+
+#include <string>
+#include <vector>
+
+#include "crypto/default/cms/ber_parser.h"
+#include "crypto/default/cms/cms_error_code.h"
+#include "crypto/default/cms/oids.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+
+namespace credentio_cms {
+
+namespace {
+
+constexpr unsigned ContextSpecificConstructed(int n) {
+  return CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC | n;
+}
+constexpr unsigned ContextSpecific(int n) {
+  return CBS_ASN1_CONTEXT_SPECIFIC | n;
+}
+
+// Scoped class making it easier to keep track of the constructed tag by
+// calling EndConstructed on destruction.
+class ScopedConstructedElement {
+ public:
+  // Only calls EndConstructed when destroyed, use it when the constructed tag
+  // was already read (usually with GetOptionalTag).
+  explicit ScopedConstructedElement(BerParser* parser, const char* hint)
+      : parser_(parser) {
+    parser->SetElementName(hint);
+  }
+  // Reads expected_tag, provides a scope name for the error messages and calls
+  // EndConstructed when destroyed.
+  ScopedConstructedElement(unsigned expected_tag, const char* hint,
+                           BerParser* parser)
+      : parser_(parser) {
+    parser->GetTag(expected_tag);
+    parser->SetElementName(hint);
+  }
+  ~ScopedConstructedElement() { parser_->EndConstructed(); }
+
+ private:
+  BerParser* parser_;  // Not owned.
+};
+
+// Some applications encode OCTET STRING as a constructed element composed
+// of octet strings.
+void ParseBerOctetString(int tag, BerParser* parser,
+                         std::vector<ByteString>* data) {
+  data->clear();
+  if (parser->Peek(tag)) {
+    data->push_back(ByteString());
+    parser->GetElementData(tag, data->back().cbs_ptr());
+    return;
+  }
+  ScopedConstructedElement constructed_string(tag | CBS_ASN1_CONSTRUCTED,
+                                              "BerOctetString", parser);
+  for (; !parser->IsNullOrEmpty();) {
+    data->push_back(ByteString());
+    parser->GetElementData(CBS_ASN1_OCTETSTRING, data->back().cbs_ptr());
+  }
+}
+
+// Expect a sequence with a single OID in it.
+void ParseAlgorithmIdentifier(const char* hint, BerParser* parser,
+                              AlgorithmIdentifier* id) {
+  parser->GetAnyElementAndSkipChildren(id->raw_value.cbs_ptr());
+  // Extract the algorithm identifier parts.
+  // https://tools.ietf.org/html/rfc5280#section-4.1.1.2
+  BerParser id_parser(CBS_data(id->raw_value.cbs_ptr()),
+                      CBS_len(id->raw_value.cbs_ptr()));
+  id_parser.GetTag(CBS_ASN1_SEQUENCE);
+  id_parser.GetElementData(CBS_ASN1_OBJECT, id->algorithm_oid.cbs_ptr());
+  if (!id_parser.IsNullOrEmpty()) {
+    id_parser.GetAnyElementAndSkipChildren(id->parameter.cbs_ptr());
+  }
+  id_parser.EndConstructed();
+  if (!id_parser.ok()) {
+    parser->SetError(id_parser.status(), hint);
+  }
+}
+
+template <typename T>
+void GetIssuerAndSerialNumber(BerParser* parser, T* ids) {
+  ScopedConstructedElement issuer_and_serial_number(
+      CBS_ASN1_SEQUENCE, "Issuer and serial number", parser);
+  parser->GetAnyElementAndSkipChildren(ids->issuer_name.cbs_ptr());
+  parser->GetElementData(CBS_ASN1_INTEGER, ids->serial_number.cbs_ptr());
+}
+
+// Sn = Serial Number
+// Ski = Subject Key Identifier
+template <typename T>
+void GetIssuerAndSnOrSki(BerParser* parser, T* ids) {
+  if (parser->Peek(CBS_ASN1_SEQUENCE)) {
+    GetIssuerAndSerialNumber(parser, ids);
+  } else if (parser->Peek(ContextSpecific(0))) {
+    parser->GetElementData(ContextSpecific(0),
+                           ids->subject_key_identifier.cbs_ptr());
+  }
+}
+
+// http://tools.ietf.org/html/rfc5652#section-6.2.1
+void ParseKeyTransRecipientInfo(BerParser* parser, RecipientInfo* recipient) {
+  ScopedConstructedElement key_agree(parser, "KeyTransRecipientInfo");
+  parser->SetElementName("RecipientInfo");
+  recipient->version = parser->GetInt();
+  if (recipient->version != 0 && recipient->version != 2) {
+    parser->SetError(static_cast<ErrorCode>(ErrorCode::UNSUPPORTED),
+                     "Unsupported recipient version");
+    return;
+  }
+  recipient->encrypted_keys.push_back(RecipientEncryptedKey());
+  GetIssuerAndSnOrSki(parser, &recipient->encrypted_keys.back());
+  ParseAlgorithmIdentifier("Key Encryption Algorithm", parser,
+                           &recipient->key_encryption_algorithm);
+  parser->GetElementData(
+      CBS_ASN1_OCTETSTRING,
+      recipient->encrypted_keys.back().encrypted_key.cbs_ptr());
+}
+
+void ParseRecipientEncryptedKey(BerParser* parser, RecipientEncryptedKey* key) {
+  ScopedConstructedElement recipient_key(CBS_ASN1_SEQUENCE,
+                                         "RecicientEncryptedKey", parser);
+  if (parser->Peek(CBS_ASN1_SEQUENCE)) {
+    GetIssuerAndSerialNumber(parser, key);
+  } else {
+    ScopedConstructedElement rid(ContextSpecificConstructed(0),
+                                 "RecipientKeyIdentifier", parser);
+    GetIssuerAndSerialNumber(parser, key);
+    parser->GetOptionalElementData(CBS_ASN1_GENERALIZEDTIME,
+                                   key->date.cbs_ptr());
+    if (!parser->IsNullOrEmpty()) {
+      parser->GetAnyElementAndSkipChildren(key->other.cbs_ptr());
+    }
+  }
+  parser->GetElementData(CBS_ASN1_OCTETSTRING, key->encrypted_key.cbs_ptr());
+}
+
+// http://tools.ietf.org/html/rfc5652#section-6.2.2
+void ParseOriginatorPublicKey(BerParser* parser,
+                              OriginatorIdentifierOrKey* oik) {
+  ScopedConstructedElement opk(parser, "OriginatorPublicKey");
+  ParseAlgorithmIdentifier("Public Key Algorithm", parser,
+                           &oik->public_key_algorithm);
+  parser->GetElementData(CBS_ASN1_BITSTRING, oik->public_key_value.cbs_ptr());
+}
+
+// http://tools.ietf.org/html/rfc5652#section-6.2.2
+void ParseOriginatorIdentifierOrKey(BerParser* parser,
+                                    OriginatorIdentifierOrKey* oik) {
+  ScopedConstructedElement originator(ContextSpecificConstructed(0),
+                                      "OriginatorIdentifierOrKey", parser);
+  if (parser->Peek(CBS_ASN1_SEQUENCE)) {
+    GetIssuerAndSerialNumber(parser, oik);
+  } else if (parser->GetOptionalTag(ContextSpecificConstructed(0))) {
+    parser->GetElementData(CBS_ASN1_OCTETSTRING,
+                           oik->subject_key_identifier.cbs_ptr());
+    parser->EndConstructed();
+  } else if (parser->GetOptionalTag(ContextSpecificConstructed(1))) {
+    ParseOriginatorPublicKey(parser, oik);
+  } else {
+    parser->SetError(static_cast<ErrorCode>(ErrorCode::UNSUPPORTED),
+                     "Unsupported originator tag");
+  }
+}
+
+// http://tools.ietf.org/html/rfc5652#section-6.2.2
+void ParseKeyAgreeRecipientInfo(BerParser* parser, RecipientInfo* recipient) {
+  ScopedConstructedElement key_agree(parser, "KeyAgreeRecipientInfo");
+  recipient->version = parser->GetInt();
+  if (recipient->version != 3) {
+    parser->SetError(static_cast<ErrorCode>(ErrorCode::UNSUPPORTED),
+                     "Unsupported key agree recipient version");
+    return;
+  }
+  ParseOriginatorIdentifierOrKey(parser,
+                                 &recipient->originator_identifier_or_key);
+  // User Key Material (UKM)
+  if (parser->GetOptionalTag(ContextSpecificConstructed(1))) {
+    parser->GetElementData(CBS_ASN1_OCTETSTRING,
+                           recipient->user_keying_material.cbs_ptr());
+    parser->EndConstructed();
+  }
+  ParseAlgorithmIdentifier("Key Encryption Algorithm", parser,
+                           &recipient->key_encryption_algorithm);
+
+  ScopedConstructedElement recipient_keys(CBS_ASN1_SEQUENCE,
+                                          "RecipientEncryptedKeys", parser);
+  for (; !parser->IsNullOrEmpty();) {
+    RecipientEncryptedKey key;
+    ParseRecipientEncryptedKey(parser, &key);
+    recipient->encrypted_keys.push_back(key);
+  }
+}
+
+// https://tools.ietf.org/html/rfc5751#section-2.5.3
+bool ExtractSmimeEncryptionKeyPreference(SignerInfo* signer,
+                                         ByteString key_preference) {
+  SmimeEncryptionKeyPreference& preference = signer->encryption_key_preference;
+  BerParser parser(CBS_data(key_preference.cbs_ptr()),
+                   CBS_len(key_preference.cbs_ptr()));
+  if (parser.Peek(ContextSpecificConstructed(0))) {
+    ScopedConstructedElement issuer_sn(ContextSpecificConstructed(0),
+                                       "IssuerAndSerialNumber", &parser);
+    parser.GetAnyElementAndSkipChildren(preference.issuer_name.cbs_ptr());
+    parser.GetElementData(CBS_ASN1_INTEGER, preference.serial_number.cbs_ptr());
+  } else if (parser.Peek(ContextSpecificConstructed(1))) {
+    ScopedConstructedElement issuer_sn(ContextSpecificConstructed(1),
+                                       "RecipientKeyIdentifier", &parser);
+    parser.GetElementData(CBS_ASN1_OCTETSTRING,
+                          preference.subject_key_identifier.cbs_ptr());
+    parser.GetOptionalElementData(CBS_ASN1_GENERALIZEDTIME,
+                                  preference.date.cbs_ptr());
+    parser.GetAnyElementAndSkipChildren(preference.other.cbs_ptr());
+  } else if (parser.Peek(ContextSpecificConstructed(2))) {
+    parser.GetElementData(ContextSpecific(2),
+                          preference.subject_key_identifier.cbs_ptr());
+  } else {
+    return false;
+  }
+  return parser.ok();
+}
+
+bool ExtractSingleValue(const Attribute& attribute, unsigned expected_tag,
+                        CBS* out) {
+  if (attribute.values.size() != 1) {
+    return false;
+  }
+  CBS copy;
+  CBS_init(&copy, CBS_data(attribute.values[0].cbs_ptr()),
+           CBS_len(attribute.values[0].cbs_ptr()));
+  return CBS_get_asn1(&copy, out, expected_tag) == 1;
+}
+
+bool ExtractCommonAttributes(SignerInfo* signer) {
+  bool status = true;
+  for (const auto& attribute : signer->signed_attributes) {
+    if (CompareOid(attribute.type.cbs(), kContentAttributeType,
+                   sizeof(kContentAttributeType))) {
+      status &= ExtractSingleValue(attribute, CBS_ASN1_OBJECT,
+                                   signer->content_type_signed.cbs_ptr());
+    } else if (CompareOid(attribute.type.cbs(), kContentAttributeMessageDigest,
+                          sizeof(kContentAttributeMessageDigest))) {
+      status &= ExtractSingleValue(attribute, CBS_ASN1_OCTETSTRING,
+                                   signer->message_digest.cbs_ptr());
+    } else if (CompareOid(attribute.type.cbs(),
+                          kSmimeEncryptionKeyPreferenceOid,
+                          sizeof(kSmimeEncryptionKeyPreferenceOid))) {
+      status &= (attribute.values.size() == 1);
+      if (status) {
+        status &=
+            ExtractSmimeEncryptionKeyPreference(signer, attribute.values[0]);
+      }
+    }
+  }
+  return status;
+}
+
+// Extracts the message digest and content type from the signed attributes.
+// http://tools.ietf.org/html/rfc5652#section-5.3
+void ParseSignedAttributes(BerParser* parser, SignerInfo* signer) {
+  // Keep the raw signed attributes to make the signature check easier.
+  parser->GetAnyElementAndSkipChildren(signer->raw_signed_attributes.cbs_ptr());
+  // Extract the content type and content digest.
+  BerParser att_parser(CBS_data(signer->raw_signed_attributes.cbs_ptr()),
+                       CBS_len(signer->raw_signed_attributes.cbs_ptr()));
+  att_parser.GetTag(ContextSpecificConstructed(0));
+  for (; !att_parser.IsNullOrEmpty();) {
+    ScopedConstructedElement seq(CBS_ASN1_SEQUENCE, "Authenticated Attributes",
+                                 &att_parser);
+    signer->signed_attributes.push_back(Attribute());
+    Attribute& attribute = signer->signed_attributes.back();
+    att_parser.GetElementData(CBS_ASN1_OBJECT, attribute.type.cbs_ptr());
+    ScopedConstructedElement s(CBS_ASN1_SET, "Signed attribute values",
+                               &att_parser);
+    for (; !att_parser.IsNullOrEmpty();) {
+      attribute.values.push_back(ByteString());
+      att_parser.GetAnyElementAndSkipChildren(
+          attribute.values.back().cbs_ptr());
+    }
+  }
+  att_parser.EndConstructed();
+  if (!att_parser.ok()) {
+    parser->SetError(att_parser.status(),
+                     "Failed to parse the signed attributes");
+    return;
+  }
+  if (!ExtractCommonAttributes(signer)) {
+    parser->SetError(att_parser.status(),
+                     "Invalid content type or digest attributes");
+  }
+}
+
+// Extract the unsigned attributes for anybody who needs to look at them.
+// http://tools.ietf.org/html/rfc5652#section-5.3
+void ParseUnsignedAttributes(BerParser* parser, SignerInfo* signer) {
+  parser->GetAnyElementAndSkipChildren(
+      signer->raw_unsigned_attributes.cbs_ptr());
+  BerParser att_parser(CBS_data(signer->raw_unsigned_attributes.cbs_ptr()),
+                       CBS_len(signer->raw_unsigned_attributes.cbs_ptr()));
+  att_parser.GetTag(ContextSpecificConstructed(1));
+  for (; !att_parser.IsNullOrEmpty();) {
+    ScopedConstructedElement seq(CBS_ASN1_SEQUENCE, "Unsigned Attributes",
+                                 &att_parser);
+    signer->unsigned_attributes.push_back(Attribute());
+    Attribute& attribute = signer->unsigned_attributes.back();
+    att_parser.GetElementData(CBS_ASN1_OBJECT, attribute.type.cbs_ptr());
+    ScopedConstructedElement s(CBS_ASN1_SET, "Unsigned attribute values",
+                               &att_parser);
+    for (; !att_parser.IsNullOrEmpty();) {
+      attribute.values.push_back(ByteString());
+      att_parser.GetAnyElementAndSkipChildren(
+          attribute.values.back().cbs_ptr());
+    }
+  }
+  att_parser.EndConstructed();
+  if (!att_parser.ok()) {
+    parser->SetError(att_parser.status(),
+                     "Failed to parse the unsigned attributes");
+  }
+}
+
+// http://tools.ietf.org/html/rfc5652#section-5.3
+static void ParseSignerInfoInternal(BerParser* parser, SignerInfo* signer) {
+  ScopedConstructedElement signer_info(CBS_ASN1_SEQUENCE, "SignerInfo", parser);
+  signer->version = parser->GetInt();
+  if (signer->version != 1 && signer->version != 3) {
+    parser->SetError(ErrorCode::UNSUPPORTED, "Unsupported signer version");
+    return;
+  }
+  GetIssuerAndSnOrSki(parser, signer);
+  ParseAlgorithmIdentifier("Digest Algorithm", parser,
+                           &signer->digest_algorithm);
+  // Authenticated attributes
+  if (parser->Peek(ContextSpecificConstructed(0))) {
+    ParseSignedAttributes(parser, signer);
+  }
+  ParseAlgorithmIdentifier("Signature Algorithm", parser,
+                           &signer->signature_algorithm);
+  parser->GetElementData(CBS_ASN1_OCTETSTRING,
+                         signer->signature_value.cbs_ptr());
+  // Implicit tag 1: Unauthenticated attributes
+  if (parser->Peek(ContextSpecificConstructed(1))) {
+    ParseUnsignedAttributes(parser, signer);
+  }
+}
+
+void ParseCertificates(BerParser* parser,
+                       std::vector<ByteString>* certificates) {
+  ScopedConstructedElement s(ContextSpecificConstructed(0), "Certificates",
+                             parser);
+  for (; !parser->IsNullOrEmpty();) {
+    ByteString out;
+    parser->GetAnyElementAndSkipChildren(out.cbs_ptr());
+    certificates->push_back(out);
+  }
+}
+
+// Certificates Revocation Lists are ignored.
+void ParseCrls(BerParser* parser) {
+  ScopedConstructedElement s(ContextSpecificConstructed(1), "CRL", parser);
+  for (; !parser->IsNullOrEmpty();) {
+    ByteString out;
+    parser->GetAnyElementAndSkipChildren(out.cbs_ptr());
+  }
+}
+
+// https://tools.ietf.org/html/rfc5652#section-5.1
+void ParseSignedData(BerParser* parser, Content* content) {
+  content->type = ContentType::SIGNED_DATA;
+  ScopedConstructedElement signed_data(CBS_ASN1_SEQUENCE, "SignedData", parser);
+  content->version = parser->GetInt();
+  {
+    ScopedConstructedElement algorigthm_set(CBS_ASN1_SET, "AlgorithmSet",
+                                            parser);
+    for (; !parser->IsNullOrEmpty();) {
+      content->digest_algorithms.push_back(AlgorithmIdentifier());
+      ParseAlgorithmIdentifier("DigestAlgorithm", parser,
+                               &content->digest_algorithms.back());
+    }
+  }
+  {
+    ScopedConstructedElement contentInfo(CBS_ASN1_SEQUENCE, "Content", parser);
+    parser->GetElementData(CBS_ASN1_OBJECT, content->content_type.cbs_ptr());
+    content->content_tag = 0;
+    if (parser->GetOptionalTag(ContextSpecificConstructed(0))) {
+      if (parser->Peek(CBS_ASN1_OCTETSTRING) ||
+          parser->Peek(CBS_ASN1_OCTETSTRING | CBS_ASN1_CONSTRUCTED)) {
+        content->content_offsets.first = parser->GetCurrentOffset();
+        ParseBerOctetString(CBS_ASN1_OCTETSTRING, parser, &content->content);
+        content->content_offsets.second = parser->GetCurrentOffset();
+        content->content_tag = CBS_ASN1_OCTETSTRING;
+      } else if (parser->Peek(CBS_ASN1_SEQUENCE)) {
+        // PKCS#7 compatibility.
+        // See https://tools.ietf.org/html/rfc5652#section-5.2.1
+        content->content_offsets.first = parser->GetCurrentOffset();
+        ByteString bytes;
+        parser->GetAnyElementAndSkipChildren(bytes.cbs_ptr());
+        content->content_offsets.second = parser->GetCurrentOffset();
+        content->content.push_back(bytes);
+        content->content_tag = CBS_ASN1_SEQUENCE;
+      } else {
+        parser->SetError(ErrorCode::UNSUPPORTED, "Unexpected type");
+      }
+      parser->EndConstructed();  // Content
+    }
+  }
+  // Certificates
+  if (parser->Peek(ContextSpecificConstructed(0))) {
+    ParseCertificates(parser, &content->certificates);
+  }
+  // Certificate Revocation List are ignored.
+  if (parser->Peek(ContextSpecificConstructed(1))) {
+    ParseCrls(parser);
+  }
+  // Signers
+  {
+    ScopedConstructedElement signers(CBS_ASN1_SET, "Signers", parser);
+    for (; !parser->IsNullOrEmpty();) {
+      content->signers.push_back(SignerInfo());
+      ParseSignerInfoInternal(parser, &content->signers.back());
+    }
+  }
+}
+
+// http://tools.ietf.org/html/rfc5652#section-6.1
+void ParseEncryptedContentInfo(BerParser* parser, Content* content) {
+  ScopedConstructedElement s(CBS_ASN1_SEQUENCE, "EncryptedContentInfo", parser);
+  CBS oid;
+  parser->GetElementData(CBS_ASN1_OBJECT, &oid);
+  if (!CompareOid(oid, kDataOid, sizeof(kDataOid))) {
+    parser->SetError(ErrorCode::UNSUPPORTED, "Unexpected content type.");
+    return;
+  }
+  ParseAlgorithmIdentifier("content encryption algorithm", parser,
+                           &content->encryption_algorithm);
+  if (parser->Peek(CBS_ASN1_CONTEXT_SPECIFIC | CBS_ASN1_CONSTRUCTED) ||
+      parser->Peek(CBS_ASN1_CONTEXT_SPECIFIC)) {
+    content->content_offsets.first = parser->GetCurrentOffset();
+    ParseBerOctetString(CBS_ASN1_CONTEXT_SPECIFIC, parser, &content->content);
+    content->content_offsets.second = parser->GetCurrentOffset();
+  } else {
+    content->content.clear();
+  }
+}
+
+// http://tools.ietf.org/html/rfc5652#section-6.2
+void ParseRecipientsInfo(BerParser* parser, Content* content) {
+  ScopedConstructedElement recipients(CBS_ASN1_SET, "Recipients", parser);
+  for (; !parser->IsNullOrEmpty();) {
+    content->recipients.push_back(RecipientInfo());
+    if (parser->GetOptionalTag(CBS_ASN1_SEQUENCE)) {
+      // http://tools.ietf.org/html/rfc2630#section-6.2.1
+      ParseKeyTransRecipientInfo(parser, &content->recipients.back());
+    } else if (parser->GetOptionalTag(ContextSpecificConstructed(1))) {
+      // http://tools.ietf.org/html/rfc2630#section-6.2.2
+      ParseKeyAgreeRecipientInfo(parser, &content->recipients.back());
+    } else {
+      // Unsupported:
+      // KEKRecipientInfo, PasswordRecipientInfo, OtherRecipientInfo
+      parser->SetError(ErrorCode::UNSUPPORTED, "Unsupported recipient type.");
+      return;
+    }
+  }
+}
+
+// https://tools.ietf.org/html/rfc5652#section-6.1
+void ParseEnvelopedData(BerParser* parser, Content* content) {
+  content->type = ContentType::ENVELOPED_DATA;
+  ScopedConstructedElement enveloped_data(CBS_ASN1_SEQUENCE, "EnvelopedData",
+                                          parser);
+  content->version = parser->GetInt();
+  if (content->version != 0 && content->version != 2) {
+    parser->SetError(ErrorCode::UNSUPPORTED, "Unsupported envelope version.");
+    return;
+  }
+  if (parser->Peek(ContextSpecificConstructed(0))) {
+    ScopedConstructedElement s(ContextSpecificConstructed(0), "OriginatorInfo",
+                               parser);
+    if (parser->Peek(ContextSpecificConstructed(0))) {
+      ParseCertificates(parser, &content->certificates);
+    }
+    if (parser->Peek(ContextSpecificConstructed(1))) {
+      ParseCrls(parser);
+    }
+  }
+  ParseRecipientsInfo(parser, content);
+  ParseEncryptedContentInfo(parser, content);
+  // UnprotectedAttributes are not used in this implementation.
+  if (parser->Peek(ContextSpecificConstructed(1))) {
+    CBS dummy;
+    parser->GetAnyElementAndSkipChildren(&dummy);
+  }
+}
+
+}  // namespace
+
+// RFC 5652 (partial support), RFC 2633 SMIME and RFC 2315 PKCS #7
+ErrorCode ParseCms(const char* data, size_t size, Content* contents,
+                   std::string* error_message) {
+  BerParser parser(reinterpret_cast<const uint8_t*>(data), size);
+  parser.SetElementName("ContentInfo");
+  parser.GetTag(CBS_ASN1_SEQUENCE);
+  CBS oid;
+  parser.GetElementData(CBS_ASN1_OBJECT, &oid);
+  parser.GetTag(ContextSpecificConstructed(0));
+  if (CompareOid(oid, kSignedDataOid, sizeof(kSignedDataOid))) {
+    ParseSignedData(&parser, contents);
+  } else if (CompareOid(oid, kEnvelopedDataOid, sizeof(kEnvelopedDataOid))) {
+    ParseEnvelopedData(&parser, contents);
+  } else {
+    parser.SetError(ErrorCode::UNSUPPORTED, "Unsupported data type.");
+  }
+  parser.EndConstructed();
+  *error_message = parser.error_message();
+  return parser.status();
+}
+
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/cms_parser.h b/crypto/default/cms/cms_parser.h
new file mode 100644
index 0000000..a34528f
--- /dev/null
+++ b/crypto/default/cms/cms_parser.h
@@ -0,0 +1,174 @@
+// 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_CRYPTO_DEFAULT_CMS_CMS_PARSER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_PARSER_H_
+
+#include <cstddef>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "crypto/default/cms/cms_error_code.h"
+#include "openssl/bytestring.h"
+
+namespace credentio_cms {
+
+// Class making sure that the CRYPTO ByteString used are always initialized.
+class ByteString {
+ public:
+  ByteString() { CBS_init(&cbs_, nullptr, 0); }
+  CBS* cbs_ptr() { return &cbs_; }
+  const CBS* cbs_ptr() const { return &cbs_; }
+  const CBS& cbs() const { return cbs_; }
+
+ private:
+  CBS cbs_;
+};
+
+// https://tools.ietf.org/html/rfc5280#section-4.1.1.2
+struct AlgorithmIdentifier {
+  ByteString algorithm_oid;
+  ByteString parameter;
+  // The un-parsed bytes so that d2i_X509_ALGOR can be used.
+  ByteString raw_value;
+};
+
+// https://tools.ietf.org/html/rfc5652#section-6.2.2
+struct OriginatorIdentifierOrKey {
+  // The issuer name and serial numbers are used if the subject_key_identifier
+  // is not set.
+  ByteString issuer_name;
+  ByteString serial_number;
+  ByteString subject_key_identifier;
+  AlgorithmIdentifier public_key_algorithm;
+  ByteString public_key_value;
+};
+
+// https://tools.ietf.org/html/rfc5652#section-6.2.2
+struct RecipientEncryptedKey {
+  // The issuer name and serial numbers are used if the subject_key_identifier
+  // is not set.
+  ByteString issuer_name;
+  ByteString serial_number;
+  ByteString subject_key_identifier;
+  // Optional
+  ByteString date;
+  // Optional OtherKeyAttribute from the RecipientKeyIdentifier.
+  ByteString other;
+  ByteString encrypted_key;
+};
+
+// https://tools.ietf.org/html/rfc5652#section-5.3
+// Signed or unsigned attribute.
+struct Attribute {
+  ByteString type;
+  std::vector<ByteString> values;
+};
+
+// https://tools.ietf.org/html/rfc5751#section-2.5.3
+struct SmimeEncryptionKeyPreference {
+  // If the SMIMEEncryptionKeyPreference is not present all the fields will be
+  // empty.
+  ByteString issuer_name;
+  ByteString serial_number;
+  ByteString subject_key_identifier;
+  // Optional
+  ByteString date;
+  // Optional OtherKeyAttribute from the RecipientKeyIdentifier.
+  ByteString other;
+};
+
+struct SignerInfo {
+  int version;
+  // The issuer name and serial numbers are used if the subject_key_identifier
+  // is not set.
+  ByteString issuer_name;
+  ByteString serial_number;
+  ByteString subject_key_identifier;
+  AlgorithmIdentifier digest_algorithm;
+  ByteString raw_signed_attributes;
+  ByteString raw_unsigned_attributes;
+  // The 'message_digest' and 'content_type_signed' fields are extracted from
+  // the signed attributes (if present).
+  ByteString message_digest;
+  ByteString content_type_signed;
+  // All the signed attributes other than message digest and content type.
+  std::vector<Attribute> signed_attributes;
+  std::vector<Attribute> unsigned_attributes;
+
+  AlgorithmIdentifier signature_algorithm;
+  ByteString signature_value;
+  SmimeEncryptionKeyPreference encryption_key_preference;
+};
+
+// https://tools.ietf.org/html/rfc5652#section-6.2.1
+struct RecipientInfo {
+  int version;
+  OriginatorIdentifierOrKey originator_identifier_or_key;
+  // Used for some key agreement algorithms.
+  ByteString user_keying_material;
+  AlgorithmIdentifier key_encryption_algorithm;
+  std::vector<RecipientEncryptedKey> encrypted_keys;
+};
+
+// https://tools.ietf.org/html/rfc5652#section-11.1
+enum class ContentType {
+  // Using the last digit of the OID defined in RFC 2315 section 14 as the enum
+  // value.
+  DATA = 1,
+  SIGNED_DATA = 2,
+  ENVELOPED_DATA = 3,
+  SIGNED_AND_ENVELOPED_DATA = 4,
+  DIGESTED_DATA = 5,
+  ENCRYPTED_DATA = 6
+};
+
+struct Content {
+  enum ContentType type;
+  int version;
+  std::vector<AlgorithmIdentifier> digest_algorithms;
+  // The encapsulated_content_info.type for signed data,
+  // encrypted_content_info.type for enveloped data.
+  ByteString content_type;
+  // The content can be absent, for example in detached signatures, or it
+  // can be in one or more chunks.
+  std::vector<ByteString> content;
+  // ASN.1 tag of the content (CBS_ASN1_OCTETSTRING, etc.)
+  unsigned content_tag;
+  // The byte stream offsets of the first content tag byte and the first byte
+  // after the content.
+  std::pair<size_t, size_t> content_offsets;
+  // The encryption_algorithm_identifier and encryption_algorithm_iv are used
+  // for enveloped data.
+  AlgorithmIdentifier encryption_algorithm;
+  std::vector<ByteString> certificates;
+  std::vector<SignerInfo> signers;
+  std::vector<RecipientInfo> recipients;
+};
+
+// Zero copy CMS parser.
+//
+// - 'data' and 'size' should point to the binary data. This data must remain
+//   valid as long
+//   as 'contents' is used.
+// - 'contents' is a view into the data and is only valid if the function
+//   returns ErrorCode::OK.
+ErrorCode ParseCms(const char* data, size_t size, Content* contents,
+                   std::string* error_message);
+
+}  // namespace credentio_cms
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_PARSER_H_
diff --git a/crypto/default/cms/cms_parser_test.cc b/crypto/default/cms/cms_parser_test.cc
new file mode 100644
index 0000000..eda7005
--- /dev/null
+++ b/crypto/default/cms/cms_parser_test.cc
@@ -0,0 +1,747 @@
+// 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 "crypto/default/cms/cms_parser.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/strings/escaping.h"
+#include "crypto/default/cms/cms_error_code.h"
+#include "crypto/default/cms/cms_test_helper.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/bytestring.h"
+#include "openssl/obj_mac.h"
+
+namespace credentio_cms {
+namespace {
+
+using testing::HasSubstr;
+
+// DER encoded Oids.
+const char kSha1Oid[] = "2b0e03021a";
+const char kSha256Oid[] = "608648016503040201";
+const char kAes256CbcOid[] = "60864801650304012a";
+const char kRsaEncryptionOid[] = "2a864886f70d010101";
+const char kCmsDataOid[] = "2a864886f70d010701";
+const char kDesEde3Cbc[] = "2a864886f70d0307";
+const char kEcdhStdSha1KdfOid[] = "2b81051086483f0002";
+// The one line issuer name for User1 and User2 certificates.
+const char kTestIssuer[] =
+    "C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA";
+const char kUser1NoCnSerialNumber[] = "1235";
+const char kUser2NoCnSerialNumber[] = "1236";
+const char kUser2BothSerialNumber[] = "1237";
+const char kUser1EccSerialNumber[] = "123b";
+const char kUser1EcdsaSerialNumber[] =
+    "6bef0b67723662c54e991de365cddf84e2a402cd";
+
+std::string PemToDer(const std::string& cert) {
+  const char kStart[] = "-----BEGIN CERTIFICATE-----";
+  int start = cert.find(kStart) + sizeof(kStart);
+  CHECK_GE(start, 0);
+  int end = cert.find("-----END CERTIFICATE-----", start);
+  CHECK_GT(end, 0);
+  std::string decoded;
+  CHECK(absl::Base64Unescape(cert.substr(start, end - start), &decoded));
+  return decoded;
+}
+
+std::string CBSToString(const ByteString& b) {
+  std::string s;
+  s.assign(reinterpret_cast<const char*>(CBS_data(b.cbs_ptr())),
+           CBS_len(b.cbs_ptr()));
+  return s;
+}
+
+std::string CBSChunksToString(const std::vector<ByteString>& data) {
+  std::string s;
+  for (const auto& chunk : data) {
+    s.append(reinterpret_cast<const char*>(CBS_data(chunk.cbs_ptr())),
+             CBS_len(chunk.cbs_ptr()));
+  }
+  return s;
+}
+
+std::string CBSToHex(const ByteString& b) {
+  return absl::BytesToHexString(CBSToString(b));
+}
+
+TEST(ParseCms, OpenSSLDetached) {
+  std::string cms = GetCms(GetFileContents("signed-openssl-detached.msg"));
+  std::string ca_cert = PemToDer(GetFileContents("ca.pem"));
+  std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message))
+      << error_message;
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(2, signed_data.certificates.size());
+  EXPECT_EQ(ca_cert, CBSToString(signed_data.certificates[0]));
+  EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[1]));
+  EXPECT_EQ(0, signed_data.content.size());
+  // The message is not encrypted.
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter));
+  // No recipient info in signed data.
+  EXPECT_EQ(0, signed_data.recipients.size());
+
+  // One signer.
+  ASSERT_EQ(1, signed_data.signers.size());
+  const auto& signer = signed_data.signers[0];
+  EXPECT_EQ(1, signer.version);
+  EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer.serial_number));
+  EXPECT_EQ(kTestIssuer, CBSToName(signer.issuer_name));
+  EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1",
+            CBSToHex(signer.message_digest));
+
+  EXPECT_EQ(
+      "6d6a93c0e9faeeb01ad176087975f8ac27218074afe7def711520b3ac99159aa627e00ad"
+      "07cfd0c5eca749950c896b51dea87b706f5ca85b1f8f5da8e2144befe9d396d5fbf9dc2b"
+      "fa7acd2f0db6f57ac8dc3daca2aea85263fe7d56c544bce58b3c49b1f78150ac6ba06d62"
+      "075be7c9165a41d5ab525b7cfc2631c0b70339bc84c2de9aa54c08393f362e2ea16f2e1a"
+      "e6a585da4daeb269495fb676b451bd9ed10669be940b2b40a952a4cabc775dc08182d304"
+      "a5b718d5562133a469302c50d09f6176d539bf300dee6c3014b5fe3dad1c2ea39149d9db"
+      "77bc5c64fdeb611a5ae4d7ab073532ffa4a439736fbd0525c742ebd93a2d234c8acac942"
+      "f80dd2ea",
+      CBSToHex(signer.signature_value));
+  EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed));
+  EXPECT_EQ(kSha256Oid, CBSToHex(signer.digest_algorithm.algorithm_oid));
+  EXPECT_EQ(kRsaEncryptionOid,
+            CBSToHex(signer.signature_algorithm.algorithm_oid));
+  EXPECT_EQ("300d06092a864886f70d0101010500",
+            CBSToHex(signer.signature_algorithm.raw_value));
+}
+
+TEST(ParseCms, OpenSSLOpaque) {
+  std::string cms = GetCms(GetFileContents("signed-openssl-opaque.msg"));
+  std::string ca_cert = PemToDer(GetFileContents("ca.pem"));
+  std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message));
+
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(2, signed_data.certificates.size());
+  EXPECT_EQ(ca_cert, CBSToString(signed_data.certificates[0]));
+  EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[1]));
+  EXPECT_EQ(
+      "Content-Type: text/plain\r\n\r\nThis is a message.\r\n"
+      "There are many like it, but this one is mine.\r\n",
+      CBSChunksToString(signed_data.content));
+  // The message is not encrypted.
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter));
+  // No recipient info in signed data.
+  EXPECT_EQ(0, signed_data.recipients.size());
+
+  // One signer.
+  ASSERT_EQ(1, signed_data.signers.size());
+  const auto& signer = signed_data.signers[0];
+  EXPECT_EQ(1, signer.version);
+  EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer.serial_number));
+  EXPECT_EQ(kTestIssuer, CBSToName(signer.issuer_name));
+  EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1",
+            CBSToHex(signer.message_digest));
+  EXPECT_EQ(
+      "323ef3ee26aa0bf4ccfc147a73536dabfeda7ee84f6c32ff96b4669a790e69f4f07e80cd"
+      "ff7357dd5ede286a33dd0985bd8a55aa531f8f93fb33ac336a0fddbcb5a14ad4337fd15c"
+      "fb63a16e72a3f2b7ef34a8d868767fd0b5d6316c44402ff2993a969c08c41496048e08be"
+      "18eedf1289fd9242a6b7b9a1f242cea7d2c86f097ddf8b241797856a82c3a8ccb0376a3c"
+      "82b79feba37886c96a6f00192ac243b310a99665d6653dcde777c22861828a6aa39867b5"
+      "a600ac6e5ab4eb1460dd0c2355c402ef8db6631e818d09dd1c66e81d87efd02fadf1b063"
+      "7fadcf930d531c8519518e33890b274bdf0a51fe51c68a4b14fc30fc01ec9a8d83b0c637"
+      "d3434565",
+      CBSToHex(signer.signature_value));
+
+  EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed));
+  EXPECT_EQ(kSha256Oid, CBSToHex(signer.digest_algorithm.algorithm_oid));
+  EXPECT_EQ(kRsaEncryptionOid,
+            CBSToHex(signer.signature_algorithm.algorithm_oid));
+}
+
+TEST(ParseCms, OpenSSLEcdsaOpaque) {
+  std::string cms = GetCms(GetFileContents("signed-openssl-ecdsa.msg"));
+  std::string user_cert = PemToDer(GetFileContents("user1_ecdsa.pem"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message));
+
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(1, signed_data.certificates.size());
+  EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[0]));
+  EXPECT_EQ(
+      "Content-Type: text/plain\r\n\r\nThis is a message.\r\n"
+      "There are many like it, but this one is mine.\r\n",
+      CBSChunksToString(signed_data.content));
+  // The message is not encrypted.
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter));
+  // No recipient info in signed data.
+  EXPECT_EQ(0, signed_data.recipients.size());
+
+  // One signer.
+  ASSERT_EQ(1, signed_data.signers.size());
+  const auto& signer = signed_data.signers[0];
+  EXPECT_EQ(1, signer.version);
+  EXPECT_EQ(kUser1EcdsaSerialNumber, CBSToHex(signer.serial_number));
+  // This particular certificate is self-signed.
+  EXPECT_EQ("emailAddress=user1@foo.bar", CBSToName(signer.issuer_name));
+  EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1",
+            CBSToHex(signer.message_digest));
+  EXPECT_EQ(
+      "304402200c42ce53ff672e2adf9d87da9c3c2d6346894d68dff9cc23ddaf1bd96cf42cd1"
+      "02206d6b318cbea8a477aa540c4ec9eebdc63281e3a16258540eb8cd5f010c91dd59",
+      CBSToHex(signer.signature_value));
+
+  EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed));
+  EXPECT_EQ(kSha256Oid, CBSToHex(signer.digest_algorithm.algorithm_oid));
+  EXPECT_EQ(absl::BytesToHexString(NidToDerString(NID_ecdsa_with_SHA256)),
+            CBSToHex(signer.signature_algorithm.algorithm_oid));
+}
+
+TEST(ParseCms, OpenSSLTwoSignaturesDetached) {
+  std::string cms =
+      GetCms(GetFileContents("two-signatures-openssl-detached.msg"));
+  std::string ca_cert = PemToDer(GetFileContents("ca.pem"));
+  std::string user1_cert = PemToDer(GetFileContents("user1_no_cn.pem"));
+  std::string user2_cert = PemToDer(GetFileContents("user2_no_cn.pem"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message));
+
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(3, signed_data.certificates.size());
+  EXPECT_EQ(ca_cert, CBSToString(signed_data.certificates[0]));
+  EXPECT_EQ(user1_cert, CBSToString(signed_data.certificates[1]));
+  EXPECT_EQ(user2_cert, CBSToString(signed_data.certificates[2]));
+  EXPECT_EQ(0, signed_data.content.size());
+  // The message is not encrypted.
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter));
+  // No recipient info in signed data.
+  EXPECT_EQ(0, signed_data.recipients.size());
+
+  // Two signers.
+  ASSERT_EQ(2, signed_data.signers.size());
+  // First signer.
+  const auto& signer1 = signed_data.signers[0];
+  EXPECT_EQ(1, signer1.version);
+  EXPECT_EQ("1236", CBSToHex(signer1.serial_number));
+  EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1",
+            CBSToHex(signer1.message_digest));
+
+  EXPECT_EQ(
+      "b3ae16888b424fa19484cd823ada53856c4d79065d47a33393b23372da06afb9c38d610e"
+      "851ef25b0fee4fd17df179c8476fb02dc1cc4ed347a9ac4b6b1ec1508d31980264ea1b03"
+      "7d87d9cb3d03c462871f96f5feb3be843f356a8d167feddebbe3891e9d45a1161b44b116"
+      "cc39cf1454988e027b309f2f29a4c0efb89b41f9e2ab63b6e446c69d6fc0e0b67729921b"
+      "7c1da6aa026f3e6245d8de327f7e55a247b65f800fb0907f2c48cc6d2ef640895a01816a"
+      "370d5447ef5d13d360386b342ebac7350f046e2d609b3c76188645e694b681589445a79c"
+      "f142b2ec85cbd1ce7e35bb89040809998540dd1a6fa0eff9f0688589f7e886b1c4608a1f"
+      "8095a2eb",
+      CBSToHex(signer1.signature_value));
+  // PKCS-7.1 Data
+  EXPECT_EQ("2a864886f70d010701", CBSToHex(signer1.content_type_signed));
+  EXPECT_EQ(kSha256Oid, CBSToHex(signer1.digest_algorithm.algorithm_oid));
+  EXPECT_EQ(kRsaEncryptionOid,
+            CBSToHex(signer1.signature_algorithm.algorithm_oid));
+  EXPECT_EQ(kTestIssuer, CBSToName(signer1.issuer_name));
+
+  // Second signer.
+  const auto& signer2 = signed_data.signers[1];
+  EXPECT_EQ(1, signer2.version);
+  EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer2.serial_number));
+  EXPECT_EQ("348910ba3e8dc1d51162ee9dc634c8a0b557479ae95c592c044dc85013d6ced1",
+            CBSToHex(signer2.message_digest));
+  EXPECT_EQ(kTestIssuer, CBSToName(signer2.issuer_name));
+
+  EXPECT_EQ(
+      "6d6a93c0e9faeeb01ad176087975f8ac27218074afe7def711520b3ac99159aa627e00ad"
+      "07cfd0c5eca749950c896b51dea87b706f5ca85b1f8f5da8e2144befe9d396d5fbf9dc2b"
+      "fa7acd2f0db6f57ac8dc3daca2aea85263fe7d56c544bce58b3c49b1f78150ac6ba06d62"
+      "075be7c9165a41d5ab525b7cfc2631c0b70339bc84c2de9aa54c08393f362e2ea16f2e1a"
+      "e6a585da4daeb269495fb676b451bd9ed10669be940b2b40a952a4cabc775dc08182d304"
+      "a5b718d5562133a469302c50d09f6176d539bf300dee6c3014b5fe3dad1c2ea39149d9db"
+      "77bc5c64fdeb611a5ae4d7ab073532ffa4a439736fbd0525c742ebd93a2d234c8acac942"
+      "f80dd2ea",
+      CBSToHex(signer2.signature_value));
+  // PKCS-7.1 Data
+  EXPECT_EQ(kCmsDataOid, CBSToHex(signer2.content_type_signed));
+  EXPECT_EQ(kSha256Oid, CBSToHex(signer2.digest_algorithm.algorithm_oid));
+  EXPECT_EQ(kRsaEncryptionOid,
+            CBSToHex(signer2.signature_algorithm.algorithm_oid));
+}
+
+TEST(ParseCms, ThunderbirdSigned) {
+  std::string cms = GetCms(GetFileContents("signed-thunderbird.msg"));
+  std::string ca_cert = PemToDer(GetFileContents("ca.pem"));
+  std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message));
+
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(1, signed_data.certificates.size());
+  EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[0]));
+  EXPECT_EQ(0, signed_data.content.size());
+  // The message is not encrypted.
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter));
+  // No recipient info in signed data.
+  EXPECT_EQ(0, signed_data.recipients.size());
+
+  // One signer.
+  ASSERT_EQ(1, signed_data.signers.size());
+  const auto& signer = signed_data.signers[0];
+  EXPECT_EQ(1, signer.version);
+  EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer.serial_number));
+  EXPECT_EQ(kTestIssuer, CBSToName(signer.issuer_name));
+  EXPECT_EQ("fb749fdbc8f3e5d6d11f02e2272048f90ad0035e",
+            CBSToHex(signer.message_digest));
+
+  EXPECT_EQ(
+      "0333cefa73f44054bd78f55f6a6af9aeac546e09d52bf069a471afb0a7facc9e048e8bec"
+      "29a32a88eecc6bda7bc3abeeb9d2e8632971dde6f24387ae59dd9aeeca3f99dbb955fbf8"
+      "24ebba7a7d160af8ca650d53373af61a12da042adbd3b0ec432aa6578e49ece7ce1fe5af"
+      "8897b26a30e23f3f09cfb66997f07ec5b4b2a7b762f8bae38cf80ec056f788c4ae3c28b8"
+      "71e8fa5e7109807552ed36bb00fd65abb71e48e5af9aea9c7d888004a413e8212bb9f1b2"
+      "f3162a9f37e3664ffa6c4e500cbac0f09e362023f54cd1c02cfe05ef3bed677d9cfae8c8"
+      "9f682a531491fa8b9cb6980c4755b5c4de7b1b46c31ba5336db260538a1b109d0bf48540"
+      "709f795e",
+      CBSToHex(signer.signature_value));
+
+  EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed));
+  EXPECT_EQ(kSha1Oid, CBSToHex(signer.digest_algorithm.algorithm_oid));
+  EXPECT_EQ(kRsaEncryptionOid,
+            CBSToHex(signer.signature_algorithm.algorithm_oid));
+  EXPECT_EQ(CBSToHex(signer.encryption_key_preference.serial_number), "1235");
+  EXPECT_EQ(CBSToName(signer.encryption_key_preference.issuer_name),
+            kTestIssuer);
+}
+
+TEST(ParseCms, OutlookMac2011Signed) {
+  std::string cms = GetCms(GetFileContents("signed-outlook-mac-2011.msg"));
+  std::string ca_cert = PemToDer(GetFileContents("ca.pem"));
+  std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message));
+
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(2, signed_data.certificates.size());
+  EXPECT_EQ(user_cert, CBSToString(signed_data.certificates[0]));
+  EXPECT_EQ(ca_cert, CBSToString(signed_data.certificates[1]));
+  EXPECT_EQ(0, signed_data.content.size());
+  // The message is not encrypted.
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("", CBSToString(signed_data.encryption_algorithm.parameter));
+  // No recipient info in signed data.
+  EXPECT_EQ(0, signed_data.recipients.size());
+
+  // One signer.
+  ASSERT_EQ(1, signed_data.signers.size());
+  const auto& signer = signed_data.signers[0];
+  EXPECT_EQ(1, signer.version);
+  EXPECT_EQ(kUser1NoCnSerialNumber, CBSToHex(signer.serial_number));
+  EXPECT_EQ("33fafcff3de4de6ca57218fc6c6befd038956ab4dd7346c1deebecc86a22fb86",
+            CBSToHex(signer.message_digest));
+
+  EXPECT_EQ(
+      "b50d1583b44d9f96efaced02496976cec63df0b40026d19198d513e5d6ed4010b7a8fe26"
+      "95df5ab31bbb69faf59a91fe081a59676f0dffee406fcfa1dafb6191dec6b76a089df449"
+      "e41f4005e56433581b5630034edded3cefb4deb151d85a6a2e2a60dce8a24432a8838116"
+      "b69aff8126ae7005329727fd5764830f5bbab6e6f372e60bbf14ee772d6f4a46761bbe84"
+      "05958eb594515bc285954270c8e16e575b42a7b1dfdd3d26a65362bccda7130c36064893"
+      "9664713d88b943bf379fe94b180f6bf0fd3d3b3cae96ef43787722430542ada19162a85f"
+      "2f167695ff77f646d7c5d42202656245ff24d2ed1b6c95ad2c3e530c3733195c5ba7d831"
+      "431ec31d",
+      CBSToHex(signer.signature_value));
+
+  EXPECT_EQ(kCmsDataOid, CBSToHex(signer.content_type_signed));
+  EXPECT_EQ(kSha256Oid, CBSToHex(signer.digest_algorithm.algorithm_oid));
+  EXPECT_EQ(kRsaEncryptionOid,
+            CBSToHex(signer.signature_algorithm.algorithm_oid));
+}
+
+// Testing signed message produced by DoD that previously triggers a parsing
+// error due to the BER encoding of a element length <127 in the 2-byte long
+// form.
+TEST(ParseCms, DoDCustomSingerSigned) {
+  // a signed message produced by DoD
+  std::string cms = GetCms(GetFileContents("dod_signed.msg"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message))
+      << error_message;
+
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(4, signed_data.certificates.size());
+  ASSERT_EQ(1, signed_data.signers.size());
+  ASSERT_EQ(6, signed_data.signers[0].signed_attributes.size());
+}
+
+TEST(ParseCms, DoDCustomSingerSignedLong) {
+  // a signed message produced by DoD
+  std::string cms = GetCms(GetFileContents("dod_signed_long.msg"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message))
+      << error_message;
+
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(5, signed_data.certificates.size());
+  ASSERT_EQ(1, signed_data.signers.size());
+  ASSERT_EQ(6, signed_data.signers[0].signed_attributes.size());
+}
+
+TEST(ParseCms, OutlookMac2011Encrypted) {
+  std::string cms = GetCms(GetFileContents("encrypted-outlook-mac-2011.msg"));
+  std::string ca_cert = PemToDer(GetFileContents("ca.pem"));
+  std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem"));
+  Content enveloped_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message))
+      << error_message;
+
+  EXPECT_EQ(ContentType::ENVELOPED_DATA, enveloped_data.type);
+  EXPECT_EQ(0, enveloped_data.version);
+  ASSERT_EQ(0, enveloped_data.certificates.size());
+
+  // Two recipients: the actual recipient and the sender so they can read
+  // what was sent.
+  ASSERT_EQ(2, enveloped_data.recipients.size());
+  const auto& signer = enveloped_data.recipients[0];
+  ASSERT_EQ(0, signer.version);
+  EXPECT_EQ(kTestIssuer, CBSToName(signer.encrypted_keys[0].issuer_name));
+  EXPECT_EQ(kUser2BothSerialNumber,
+            CBSToHex(signer.encrypted_keys[0].serial_number));
+  ASSERT_EQ(kRsaEncryptionOid,
+            CBSToHex(signer.key_encryption_algorithm.algorithm_oid));
+  ASSERT_EQ(
+      "928a924ca3f4777ac22271e6ec89a773d909ba6d79ac4961ee3692c87c2654fdcbc1b3dd"
+      "355a7ac0e2d231b8b3fc6de5a89a9d1a9408339bf711e2e8a91054e6681d8668c28f1e0f"
+      "73ec68db6a63beab6873058fa7d180510fa109be99a21b33ea014185df3b13a15adfa19d"
+      "7872805037d171cc04f663233b0d350fecd890347d59f7ba445d13ecf51154dd2252f0e7"
+      "b1ed0ae663cabc3c4d77bc6f081302adfceafc29da768d9e1e29bb8e81e909ec93e65f99"
+      "c8a1224b977693c0e62dc92155abdbd38bd198b1a99167e39534817cb5dac41b4f893bf9"
+      "52c0be07f122210399b6e3ebdd3fe56d0d4dbb27d048ec11fef3580da91470e5bd026d55"
+      "6aeac0ae",
+      CBSToHex(signer.encrypted_keys[0].encrypted_key));
+
+  const auto& recipient2 = enveloped_data.recipients[1];
+  ASSERT_EQ(0, recipient2.version);
+  EXPECT_EQ(kTestIssuer, CBSToName(recipient2.encrypted_keys[0].issuer_name));
+  EXPECT_EQ(kUser1NoCnSerialNumber,
+            CBSToHex(recipient2.encrypted_keys[0].serial_number));
+  ASSERT_EQ(kRsaEncryptionOid,
+            CBSToHex(recipient2.key_encryption_algorithm.algorithm_oid));
+  ASSERT_EQ(
+      "8deec1a3c6ce33deaff7560b6f4a1844a34aada30459af5357ffb85cce07c284a189af18"
+      "ffa693553c8629b39b019e27fee0573c73f6cf2f100c7af28402d8d81d83028d45141d40"
+      "4be04b62e83d2fc4f1893f7f51068d330e1755a66618d604df63e58069c5eaf11de7b5c2"
+      "590481973cecbb2725707f5c03da360832573ebbf6d180f20d3ac475648d7b67456eae6b"
+      "264b72e75c390116bf8ad09335529b9f11cb61bb52a32c0d6b65fffe218dc429e038b5ab"
+      "0f1d102b6277da27b1bc2e708707299605d7ce3e1941e364c35568b6c1eff7c7d488b496"
+      "5b3d53fa038d82904327c4669dfccdded733ac887e00857600be875302bbce72b67020f1"
+      "9276f06e",
+      CBSToHex(recipient2.encrypted_keys[0].encrypted_key));
+
+  std::string content = CBSChunksToString(enveloped_data.content);
+  EXPECT_EQ(4576, content.size());
+  // Check the first 16 and last 16 bytes for the encrypted payload.
+  std::string hex_content = absl::BytesToHexString(content);
+  EXPECT_EQ(0, hex_content.find("75d46c500858ac2476c0b36916d49f3b"));
+  EXPECT_EQ(4560 * 2, hex_content.find("966d5b525e9a81f737e439537b74cd21"));
+
+  EXPECT_EQ(kAes256CbcOid,
+            CBSToHex(enveloped_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("0410d86dc5375a13ba12c48d3a0da6100316",
+            CBSToHex(enveloped_data.encryption_algorithm.parameter));
+  // No signer on the envelope.
+  ASSERT_EQ(0, enveloped_data.signers.size());
+}
+
+TEST(ParseCms, ThunderbirdEncrypted) {
+  std::string cms = GetCms(GetFileContents("encrypted-thunderbird.msg"));
+  Content enveloped_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message))
+      << error_message;
+
+  EXPECT_EQ(ContentType::ENVELOPED_DATA, enveloped_data.type);
+  EXPECT_EQ(0, enveloped_data.version);
+  ASSERT_EQ(0, enveloped_data.certificates.size());
+
+  // Two recipients: the actual recipient and the sender so they can read
+  // what was sent.
+  ASSERT_EQ(2, enveloped_data.recipients.size());
+  const auto& recipient1 = enveloped_data.recipients[0];
+  ASSERT_EQ(0, recipient1.version);
+  EXPECT_EQ(kTestIssuer, CBSToName(recipient1.encrypted_keys[0].issuer_name));
+  EXPECT_EQ(kUser1NoCnSerialNumber,
+            CBSToHex(recipient1.encrypted_keys[0].serial_number));
+  ASSERT_EQ(kRsaEncryptionOid,
+            CBSToHex(recipient1.key_encryption_algorithm.algorithm_oid));
+  ASSERT_EQ(
+      "c7e9154370787a7980e60e7f7ea4152be93d2247518d62091df2206d0ab12125c35dc078"
+      "74e75b9b365fc50f71f3fce8e75caac42eda81517a9a3844fdc270d99ff77468bac370dc"
+      "bbccd4f3efa859a87064b79ecfd7ed857091e150e364853942ac5f9644df0278a5984f98"
+      "12948c4e22ab00f931473cc8f2a087d22d40f5c8c188171d6829113a76f0b98cf9f7d67a"
+      "96bd5f567c28ae3eb6e7b689ce4ce5e878d6a9808b3f23cb1929e2291d63c3ac0dfac69a"
+      "d85225930b694f4023a63adda4b5be3cc8554e506d973ccf945011052cfe04e21ed8a949"
+      "b9ad0810a6f3c394a9bf34a43923f08d077c7b8c4d1e726b90d0e673fbcca8e47f1fce68"
+      "2c1df1e5",
+      CBSToHex(recipient1.encrypted_keys[0].encrypted_key));
+
+  const auto& recipient2 = enveloped_data.recipients[1];
+  ASSERT_EQ(0, recipient2.version);
+  EXPECT_EQ(kTestIssuer, CBSToName(recipient2.encrypted_keys[0].issuer_name));
+  EXPECT_EQ(kUser2NoCnSerialNumber,
+            CBSToHex(recipient2.encrypted_keys[0].serial_number));
+  ASSERT_EQ(kRsaEncryptionOid,
+            CBSToHex(recipient2.key_encryption_algorithm.algorithm_oid));
+  ASSERT_EQ(
+      "835cdd7a9e5a92541d0ed18e6c66dc9528a3ad0b6d34193da464606798c8a875b24afda6"
+      "e5dfcf181a6f4b6160f983cbd6f2964bccd66bca7d1a345c37ef5beeef573888877e5a0c"
+      "09669666bec111da66e46f3c363d6cbcc69cf67ad6ce6e2f35f1e855d4a7eb692bbe3cfa"
+      "a4d2a50872d2ff5305cb8ad60bb8947761ba7255289acdc43fb688aabaaaad658f7cb5aa"
+      "58251217608450f547a94259f1ff154d57fdc53464189aba1691cc0d8772bf2fd0775abd"
+      "4160db52ade680cf1472f87140ab90efe6be48b0179acc2f475daad745afa9fb4ea09b80"
+      "8b2947624659bf6708852c55e5354c9e2ec162f2eeb8d20e7abe64c0496c095f02e3ec95"
+      "c60e1d79",
+      CBSToHex(recipient2.encrypted_keys[0].encrypted_key));
+
+  EXPECT_EQ(
+      "107a12c35ea7c2c7ffa427256869243f5e61693d6a4b0c3bc2168c97d2dfa64d78ae09a4"
+      "acb014b99c97f3a6169336420ff25e6c0ccec1dfc147b5ba7517c786e7106a910147334b"
+      "6b8e9873624c839051a27cc6242efb8a5c6773d74b7b8748189f98bf1e124aba7df35cb2"
+      "c1cf73bf8b1267b57c673e3119405e29530e44a76c5353540ed8829c5dc553cb6f17a97f"
+      "2f1d44b538ca4ddd0f7d34c8cdb3bc7b23fdd2b380dfaa134fa9432ef8e13cb6",
+      absl::BytesToHexString(CBSChunksToString(enveloped_data.content)));
+
+  EXPECT_EQ(kDesEde3Cbc,
+            CBSToHex(enveloped_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("04085a0d5623a1e7a4a0",
+            CBSToHex(enveloped_data.encryption_algorithm.parameter));
+  // No signer on the envelope.
+  ASSERT_EQ(0, enveloped_data.signers.size());
+}
+
+TEST(ParseCms, OpenSslEncrypted) {
+  std::string cms = GetCms(GetFileContents("encrypted-openssl.msg"));
+  std::string ca_cert = PemToDer(GetFileContents("ca.pem"));
+  std::string user_cert = PemToDer(GetFileContents("user1_no_cn.pem"));
+  Content enveloped_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message))
+      << error_message;
+
+  EXPECT_EQ(ContentType::ENVELOPED_DATA, enveloped_data.type);
+  EXPECT_EQ(0, enveloped_data.version);
+  ASSERT_EQ(0, enveloped_data.certificates.size());
+
+  // Two recipients: the actual recipient and the sender so they can read
+  // what was sent.
+  ASSERT_EQ(1, enveloped_data.recipients.size());
+  const auto& recipient = enveloped_data.recipients[0];
+  ASSERT_EQ(0, recipient.version);
+  EXPECT_EQ(kTestIssuer, CBSToName(recipient.encrypted_keys[0].issuer_name));
+  EXPECT_EQ(kUser2NoCnSerialNumber,
+            CBSToHex(recipient.encrypted_keys[0].serial_number));
+  ASSERT_EQ(kRsaEncryptionOid,
+            CBSToHex(recipient.key_encryption_algorithm.algorithm_oid));
+  ASSERT_EQ(
+      "a873be9a23303b91769f8dcf641eb8ee95164710401d76db97f444fe73df1197110b1b7d"
+      "1d5c6bec413793d4c86371e9a96bd9a7313e02eb7bf6f94fcc9249237bc037df63ed4681"
+      "d7dd2ae27da31e5da4c42737516ecde4e7c61691508b4f9544348f7a5df646bf619466a1"
+      "4730d37d452bf98058b522ecd02138f2746f4b4745157753ed8d3998c88682424d19016d"
+      "11aaa0c20b8b3c2865deb2bf0f7b436bb82df10908ba8f7dc719ec11b7bae892a6e86d59"
+      "16895017b302e2cdcf9504e1271ac0b23bcf270c4d3d75c41ed24f308149f118a0e8ac29"
+      "4910ce67ff43fc379959e25f5ead1fb5354bb24ee5287d1a6d7327424fa11f0509f0f374"
+      "ebf9ecd3",
+      CBSToHex(recipient.encrypted_keys[0].encrypted_key));
+
+  EXPECT_EQ(
+      "0ad0fb4894c4c73c6d255e1c59f24b331000b0ce058cc1d2367d73a7a870eea5cfd46740"
+      "ad3fcf65f4e9acb1982937237b067692b9aa7731d39f28b0e5ad58682a3614ba3caf794e"
+      "1a71025642cb01cf401be16761d8cfbdcb41c998ad5d3704",
+      absl::BytesToHexString(CBSChunksToString(enveloped_data.content)));
+
+  EXPECT_EQ(kAes256CbcOid,
+            CBSToHex(enveloped_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("0410b0b6a6406c1a6a006c567400d664bfe9",
+            CBSToHex(enveloped_data.encryption_algorithm.parameter));
+  // No signer on the envelope.
+  ASSERT_EQ(0, enveloped_data.signers.size());
+}
+
+TEST(ParseCms, OpensslEncryptedEcc) {
+  std::string cms = GetCms(GetFileContents("encrypted-openssl-ecdh.msg"));
+  Content enveloped_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message))
+      << error_message;
+  EXPECT_EQ(ContentType::ENVELOPED_DATA, enveloped_data.type);
+  EXPECT_EQ(2, enveloped_data.version);
+  ASSERT_EQ(0, enveloped_data.certificates.size());
+
+  ASSERT_EQ(1, enveloped_data.recipients.size());
+  const auto& recipient = enveloped_data.recipients[0];
+  EXPECT_EQ(3, recipient.version);
+  EXPECT_EQ(kTestIssuer, CBSToName(recipient.encrypted_keys[0].issuer_name));
+  EXPECT_EQ(kUser1EccSerialNumber,
+            CBSToHex(recipient.encrypted_keys[0].serial_number));
+  EXPECT_EQ(kEcdhStdSha1KdfOid,
+            CBSToHex(recipient.key_encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("300f060b2a864886f70d01091003060500",
+            CBSToHex(recipient.key_encryption_algorithm.parameter));
+  EXPECT_EQ(
+      "a9ad0552a61f549516be729006670369449181fc845fd1647445d40c967062f2bc2c0a58"
+      "edb12c65",
+      CBSToHex(recipient.encrypted_keys[0].encrypted_key));
+  EXPECT_EQ(
+      "0004df6ea880dc4db5952082d0f1a19c61019f1d34b6dfde586d4515e7d43622949b4cc0"
+      "d63c6725615b5ecd0a421a8f144b53bbd53bc249abef0c2af1adc11c5e41",
+      CBSToHex(recipient.originator_identifier_or_key.public_key_value));
+  EXPECT_EQ("", CBSToHex(recipient.user_keying_material));
+  EXPECT_EQ("6854c40ae91385c43d593cb8576341f322ecb3691e5a219ee911aa28d4512a86",
+            absl::BytesToHexString(CBSChunksToString(enveloped_data.content))
+                .substr(0, 64));
+
+  EXPECT_EQ(kDesEde3Cbc,
+            CBSToHex(enveloped_data.encryption_algorithm.algorithm_oid));
+  EXPECT_EQ("301406082a864886f70d03070408a74c3bb3a5d47c6e",
+            CBSToHex(enveloped_data.encryption_algorithm.raw_value));
+  // No signer on the envelope.
+  ASSERT_EQ(0, enveloped_data.signers.size());
+}
+
+TEST(ParseCms, SimpleChecks) {
+  std::vector<std::string> files = {"encrypted-openssl-oaep.msg",
+                                    "signed-openssl-pss.msg"};
+  for (const auto& filename : files) {
+    std::string cms = GetCms(GetFileContents(filename));
+    Content contents;
+    std::string error_message;
+    auto status = ParseCms(cms.data(), cms.length(), &contents, &error_message);
+    EXPECT_EQ(ErrorCode::OK, status)
+        << "File tested: " << filename << " error: " << error_message;
+  }
+}
+
+TEST(ParseCms, RFC4134_47) {
+  std::string cms = GetCms(GetFileContents("rfc4134-4.7.msg"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message))
+      << error_message;
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(3, signed_data.version);
+  ASSERT_EQ(1, signed_data.certificates.size());
+
+  ASSERT_EQ(1, signed_data.signers.size());
+  const auto& signer = signed_data.signers[0];
+  EXPECT_EQ(3, signer.version);
+  EXPECT_EQ("be6ca1b3e3c1f7ed4370a4ce1301e2fde397fecd",
+            CBSToHex(signer.subject_key_identifier));
+}
+
+TEST(ParseCms, SignedOffice365Owa) {
+  std::string cms = GetCms(GetFileContents("signed-office365-owa.msg"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message))
+      << error_message;
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(3, signed_data.certificates.size());
+
+  ASSERT_EQ(1, signed_data.signers.size());
+  const auto& signer = signed_data.signers[0];
+  EXPECT_EQ(1, signer.version);
+}
+
+TEST(ParseCms, ParseCertificateOnly) {
+  // This is a certificates only file (degenerate signature).
+  std::string cms = GetCms(GetFileContents("rfc4134-4.11.msg"));
+  Content signed_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &signed_data, &error_message))
+      << error_message;
+  EXPECT_EQ(ContentType::SIGNED_DATA, signed_data.type);
+  EXPECT_EQ(1, signed_data.version);
+  ASSERT_EQ(2, signed_data.certificates.size());
+  ASSERT_EQ(0, signed_data.signers.size());
+}
+
+TEST(ParseCms, ParseEnvelopedDataWithOriginatorInfo) {
+  std::string b64 = GetFileContents("encrypted-with-originator-info.msg");
+  std::string cms;
+  CHECK(absl::Base64Unescape(b64, &cms));
+  Content enveloped_data;
+  std::string error_message;
+  EXPECT_EQ(ErrorCode::OK,
+            ParseCms(cms.data(), cms.length(), &enveloped_data, &error_message))
+      << error_message;
+  ASSERT_EQ(4, enveloped_data.certificates.size());
+  // Spot check the certificates.
+  EXPECT_THAT(CBSToString(enveloped_data.certificates[0]),
+              HasSubstr("AddTrust External CA Root"));
+  EXPECT_THAT(CBSToString(enveloped_data.certificates[1]),
+              HasSubstr("COMODO SHA-256 Client Authentication"));
+  EXPECT_THAT(CBSToString(enveloped_data.certificates[2]),
+              HasSubstr("tererqa3"));
+  EXPECT_THAT(CBSToString(enveloped_data.certificates[3]),
+              HasSubstr("ns-ex13.gtestexchange.com"));
+  ASSERT_EQ(2, enveloped_data.recipients.size());
+  // Check the first recipient.
+  const auto& recipient = enveloped_data.recipients[0];
+  ASSERT_EQ(0, recipient.version);
+  EXPECT_EQ(
+      "C=GB, ST=Greater Manchester, L=Salford, O=COMODO CA Limited, CN=COMODO "
+      "SHA-256 Client Authentication and Secure Email CA",
+      CBSToName(recipient.encrypted_keys[0].issuer_name));
+  EXPECT_EQ("4dc1e7496c5320405df99a567dde046f",
+            CBSToHex(recipient.encrypted_keys[0].serial_number));
+}
+
+}  // namespace
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/cms_test_helper.cc b/crypto/default/cms/cms_test_helper.cc
new file mode 100644
index 0000000..d224183
--- /dev/null
+++ b/crypto/default/cms/cms_test_helper.cc
@@ -0,0 +1,181 @@
+// 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 "crypto/default/cms/cms_test_helper.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/default/cms/cms_parser.h"
+#include "openssl/base.h"
+#include "openssl/bio.h"
+#include "openssl/bytestring.h"
+#include "openssl/obj.h"
+#include "openssl/pem.h"
+#include "openssl/x509.h"
+#include "testing/test_file_utils.h"
+
+namespace credentio_cms {
+
+constexpr absl::string_view kTestRepoDir = "c2pa/";
+
+std::string GetRawFileContents(const std::string& filename) {
+  std::string path =
+      absl::StrCat(kTestRepoDir, "crypto/default/cms/testdata/", filename);
+  auto contents = credentio_testing::GetContents(path);
+  CHECK(contents.ok()) << "Failed to load: " << path << ", "
+                       << contents.status();
+  return *contents;
+}
+
+std::string GetFileContents(const std::string& filename) {
+  std::string contents = GetRawFileContents(filename);
+  // Normalize line endings by replacing \n with \r\n when needed.
+  std::string normalized_contents;
+  bool has_cr = false;
+  for (char c : contents) {
+    if (c == '\r') {
+      has_cr = true;
+    } else {
+      if (c == '\n' && !has_cr) {
+        normalized_contents += '\r';
+      }
+      has_cr = false;
+    }
+    normalized_contents += c;
+  }
+  return normalized_contents;
+}
+
+// Quick and dirty functions to extract the CMS object. It probably only works
+// on the test cases.
+std::string GetCms(const std::string& message) {
+  auto pos = message.find("name=\"smime.p7s\"");
+  if (pos == std::string::npos) {
+    pos = message.find("name=\"smime.p7m\"");
+  }
+  if (pos == std::string::npos) {
+    pos = message.find("name=smime.p7s");
+  }
+  if (pos == std::string::npos) {
+    pos = message.find("name=smime.p7m");
+  }
+  if (pos == std::string::npos) {
+    pos = message.find("name=smime.p7c");
+  }
+  CHECK(pos != std::string::npos);
+  auto start = message.find("\x0d\x0a\x0d\x0a", pos);
+  CHECK(start != std::string::npos);
+  start += 2;
+  auto end = message.find('-', start);
+  if (end == std::string::npos) {
+    end = message.find("\x0d\x0a\x0d\x0a", start);
+  }
+  if (end == std::string::npos) {
+    end = message.size();
+  }
+  std::string decoded;
+  CHECK(absl::Base64Unescape(message.substr(start, end - start), &decoded));
+  return decoded;
+}
+
+std::string GetFirstMimePart(const std::string& contents) {
+  const char kBoundary[] = "boundary=\"";
+  const char kUnquotedBoundary[] = "boundary=";
+  int boundary_start = contents.find(kBoundary);
+  int boundary_end;
+  if (boundary_start == std::string::npos) {
+    boundary_start = contents.find(kUnquotedBoundary);
+    CHECK_NE(std::string::npos, boundary_start);
+    boundary_start += sizeof(kUnquotedBoundary) - 1;
+    boundary_end = contents.find_first_of(";\r", boundary_start);
+  } else {
+    boundary_start += sizeof(kBoundary) - 1;
+    boundary_end = contents.find('\"', boundary_start);
+  }
+  CHECK_NE(std::string::npos, boundary_end);
+  std::string boundary =
+      "--" + contents.substr(boundary_start, boundary_end - boundary_start);
+
+  int part_start = contents.find(boundary, boundary_end);
+  CHECK_NE(std::string::npos, part_start);
+  part_start = contents.find("\r\n", part_start + boundary.size());
+  CHECK_NE(std::string::npos, part_start);
+  part_start += 2;
+  int part_end = contents.find(boundary, part_start);
+  CHECK_NE(std::string::npos, part_end);
+  CHECK_GT(part_end, part_start + 2);
+  // String the CRLF that is right before the boundary end.
+  return contents.substr(part_start, part_end - part_start - 2);
+}
+
+// Convert an ASN.1 X509 name to a single line string.
+std::string CBSToName(const ByteString& raw_name) {
+  X509_NAME* name = nullptr;
+  const unsigned char* p = CBS_data(raw_name.cbs_ptr());
+  d2i_X509_NAME(&name, &p, CBS_len(raw_name.cbs_ptr()));
+  std::string line;
+  BIO* bio = BIO_new(BIO_s_mem());
+  X509_NAME_print_ex(bio, name, 0 /* indent */, 0 /* flags */);
+  int size = BIO_pending(bio);
+  std::unique_ptr<char[]> buffer(new char[size]);
+  CHECK_EQ(size, BIO_read(bio, buffer.get(), size));
+  line.assign(&buffer[0], size);
+  X509_NAME_free(name);
+  BIO_free_all(bio);
+  return line;
+}
+
+X509* PemToCertificate(const std::string& pem) {
+  bssl::UniquePtr<BIO> bio(
+      BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size()));
+  return PEM_read_bio_X509(bio.get(), nullptr /* existing key */,
+                           nullptr /* password callback */,
+                           nullptr /* password */);
+}
+
+EVP_PKEY* PemToKey(const std::string& pem) {
+  bssl::UniquePtr<BIO> bio(
+      BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size()));
+  return PEM_read_bio_PrivateKey(bio.get(), nullptr /* existing key */,
+                                 nullptr /* password callback */,
+                                 nullptr /* password */);
+}
+
+std::string NidToDerString(int nid) {
+  // Encode the OID as an ASN.1 OBJECT.
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 0));
+  CHECK(OBJ_nid2cbb(&cbb, nid));
+  CBS asn1_cbs;
+  CBS_init(&asn1_cbs, CBB_data(&cbb), CBB_len(&cbb));
+  // Copy the raw OID into the return value and discard the ASN.1 headers.
+  CBS raw_oid_cbs;
+  CHECK(CBS_get_asn1(&asn1_cbs, &raw_oid_cbs, CBS_ASN1_OBJECT));
+  size_t raw_oid_len = CBS_len(&raw_oid_cbs);
+  std::string ret(raw_oid_len, '\0');
+  CHECK(CBS_copy_bytes(&raw_oid_cbs, reinterpret_cast<uint8_t*>(ret.data()),
+                       raw_oid_len));
+  CBB_cleanup(&cbb);
+  return ret;
+}
+
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/cms_test_helper.h b/crypto/default/cms/cms_test_helper.h
new file mode 100644
index 0000000..8b71b3e
--- /dev/null
+++ b/crypto/default/cms/cms_test_helper.h
@@ -0,0 +1,59 @@
+// 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_CRYPTO_DEFAULT_CMS_CMS_TEST_HELPER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_TEST_HELPER_H_
+
+#include <string>
+
+#include "crypto/default/cms/cms_parser.h"
+#include "openssl/base.h"
+#include "openssl/ossl_typ.h"
+
+namespace credentio_cms {
+
+// Read a file from the test data folder and return its contents.
+std::string GetRawFileContents(const std::string& filename);
+
+// Read a file from the test data folder, normalizes the line endings and return
+// its contents.
+std::string GetFileContents(const std::string& filename);
+
+// Quick and dirty functions to extract the CMS object. It only works on the
+// test cases.
+std::string GetCms(const std::string& message);
+
+// Extract the first Mime part. Only suitable for tests.
+// Returns the first mime part on success or check fails on failure.
+std::string GetFirstMimePart(const std::string& contents);
+
+// Convert a byte string representing an X509 Name object to a single line
+// string.
+std::string CBSToName(const ByteString& raw_name);
+
+// Convert a PEM string to an X509 certificate. The caller needs to call
+// X509_free on the returned certificate.
+X509* PemToCertificate(const std::string& pem);
+
+// Convert a PEM string to an EVP_PKEY private key. The caller needs to call
+// EVP_PKEY_free on the returned key.
+EVP_PKEY* PemToKey(const std::string& pem);
+
+// Get the raw DER encoding of the OID with the given NID.
+std::string NidToDerString(int nid);
+
+}  // namespace credentio_cms
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_CMS_TEST_HELPER_H_
diff --git a/crypto/default/cms/oids.cc b/crypto/default/cms/oids.cc
new file mode 100644
index 0000000..8d41055
--- /dev/null
+++ b/crypto/default/cms/oids.cc
@@ -0,0 +1,38 @@
+// 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 "crypto/default/cms/oids.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+
+#include "openssl/bytestring.h"
+
+namespace credentio_cms {
+
+// Returns true if the contents of data match expected.
+bool CompareOid(const CBS& data, const uint8_t expected[],
+                size_t expected_length) {
+  return CBS_len(&data) == expected_length &&
+         memcmp(CBS_data(&data), expected, expected_length) == 0;
+}
+
+bool CompareOid(const CBS& a, const CBS& b) {
+  return CBS_len(&a) == CBS_len(&b) &&
+         memcmp(CBS_data(&a), CBS_data(&b), CBS_len(&a)) == 0;
+}
+
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/oids.h b/crypto/default/cms/oids.h
new file mode 100644
index 0000000..4e1efcd
--- /dev/null
+++ b/crypto/default/cms/oids.h
@@ -0,0 +1,121 @@
+// 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_CRYPTO_DEFAULT_CMS_OIDS_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_OIDS_H_
+
+#include <cstdint>
+
+#include "openssl/bytestring.h"
+#include "openssl/x509.h"
+
+namespace credentio_cms {
+
+// Abbreviations:
+// rsadsi = RSA Data Security Inc
+// pkcs = Public Key Cryptographic Standards
+
+// https://tools.ietf.org/html/rfc5652#section-4
+// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-7 1 }
+constexpr uint8_t kDataOid[] = {1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7,
+                                0x0d,       1,    7,    1};
+// https://tools.ietf.org/html/rfc5652#section-5.1
+// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-7 2 }
+constexpr uint8_t kSignedDataOid[] = {1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7,
+                                      0x0d,       1,    7,    2};
+// https://tools.ietf.org/html/rfc5652#section-5.3
+// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-7 3 }
+constexpr uint8_t kEnvelopedDataOid[] = {1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7,
+                                         0x0d,       1,    7,    3};
+// https://tools.ietf.org/html/rfc5652#section-11.1
+// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-9 3 }
+constexpr uint8_t kContentAttributeType[] = {1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7,
+                                             0x0d,       1,    9,    3};
+// https://tools.ietf.org/html/rfc5652#section-11.2
+// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-9 4 }
+constexpr uint8_t kContentAttributeMessageDigest[] = {
+    1 * 40 + 2, 0x86, 0x48, 0x86, 0xf7, 0x0d, 1, 9, 4};
+
+// https://tools.ietf.org/html/rfc5751
+// iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1) pkcs-9(9) 5
+constexpr uint8_t kSigningTimeOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                       0x0D, 0x01, 0x09, 0x05};
+// https://tools.ietf.org/html/rfc5751
+constexpr uint8_t kSmimeCapabilitiesOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                             0x0D, 0x01, 0x09, 0x0F};
+// https://tools.ietf.org/html/rfc7508#section-4.1
+// iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1) pkcs-9(9) smime(16)
+// attributes(2) 55
+constexpr uint8_t kSecureHeaderFieldsIdentifierOid[] = {
+    0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x10, 0x2, 55};
+// SMIMEEncryptionKeyPreference
+// https://tools.ietf.org/html/rfc5751#section-2.5.3
+// {iso(1) member-body(2) usa(840) rsadsi(113549) pkcs(1) pkcs-9(9) smime(16)
+// attributes(2)} 11
+constexpr uint8_t kSmimeEncryptionKeyPreferenceOid[] = {
+    0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x10, 0x2, 11};
+
+// http://csrc.nist.gov/groups/ST/crypto_apps_infra/csor/algorithms.html
+constexpr uint8_t kSha256Oid[] = {0x60, 0x86, 0x48, 0x01, 0x65,
+                                  0x03, 0x04, 0x02, 0x01};
+
+constexpr uint8_t kSha1WithRsaOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                       0x0D, 1,    1,    1};
+
+// https://tools.ietf.org/html/rfc5754#section-3.2
+constexpr uint8_t kSha256WithRsaOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                         0x0D, 1,    1,    11};
+// https://tools.ietf.org/html/rfc5754#section-3.2
+constexpr uint8_t kSha384WithRsaOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                         0x0D, 1,    1,    12};
+// https://tools.ietf.org/html/rfc5754#section-3.2
+constexpr uint8_t kSha512WithRsaOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                         0x0D, 1,    1,    13};
+
+// https://tools.ietf.org/html/rfc4056
+constexpr uint8_t kRsaSsaPssOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                     0x0D, 0x01, 0x01, 0x0a};
+// https://tools.ietf.org/html/rfc4056
+constexpr uint8_t kRsaEsOaepOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                     0x0D, 0x01, 0x01, 0x07};
+// https://tools.ietf.org/html/rfc4056
+constexpr uint8_t kRsaPkcs1Oid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7,
+                                    0x0D, 0x01, 0x01, 0x01};
+
+// https://tools.ietf.org/html/rfc3565
+constexpr uint8_t kAes128CbcOid[] = {0x60, 0x86, 0x48, 0x01, 0x65, 3, 4, 1, 2};
+// https://tools.ietf.org/html/rfc3565
+constexpr uint8_t kAes192CbcOid[] = {0x60, 0x86, 0x48, 0x01, 0x65, 3, 4, 1, 22};
+// https://tools.ietf.org/html/rfc3565
+constexpr uint8_t kAes256CbcOid[] = {0x60, 0x86, 0x48, 0x01, 0x65, 3, 4, 1, 42};
+
+// https://tools.ietf.org/html/rfc3370#section-5.1
+constexpr uint8_t kDesEde3CbcOid[] = {0x2A, 0x86, 0x48, 0x86,
+                                      0xF7, 0x0D, 0x03, 0x07};
+
+// https://datatracker.ietf.org/doc/html/rfc3161#section-2.4.2
+// iso(1) member-body(2) US(840) rsadsi(113549) pkcs(1) { pkcs-9 1 smime(16)
+// ct(1) 4 }
+constexpr uint8_t kCtTstInfoOid[] = {0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D,
+                                     0x01, 0x09, 0x10, 0x01, 0x04};
+
+// Returns true if the contents of data match expected.
+bool CompareOid(const CBS& data, const uint8_t expected[],
+                size_t expected_length);
+bool CompareOid(const CBS& a, const CBS& b);
+
+}  // namespace credentio_cms
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_OIDS_H_
diff --git a/crypto/default/cms/testdata/ca.pem b/crypto/default/cms/testdata/ca.pem
new file mode 100644
index 0000000..11be3cf
--- /dev/null
+++ b/crypto/default/cms/testdata/ca.pem
@@ -0,0 +1,80 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number:
+            a3:87:4b:2c:9c:e8:a1:90
+        Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Feb  6 18:04:21 2015 GMT
+            Not After : Nov 21 18:04:21 2288 GMT
+        Subject: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+                Public-Key: (2048 bit)
+                Modulus:
+                    00:cf:1e:92:80:13:8f:29:d4:b2:74:49:3a:03:d0:
+                    22:60:81:6a:ac:e3:eb:41:3b:8e:1d:2d:ec:89:92:
+                    7e:2a:e6:9f:9f:fd:bb:a3:87:d1:4f:30:37:92:f6:
+                    65:76:c5:3d:45:34:4a:83:42:74:e7:39:96:64:e6:
+                    d4:6e:24:4b:1f:56:e8:31:d8:f4:19:84:93:c2:4f:
+                    c9:bf:8f:71:b1:4c:61:26:34:aa:fd:d3:94:24:7b:
+                    69:a3:f9:0d:02:a0:90:06:3e:ad:66:9f:a5:eb:3c:
+                    f9:c8:7d:4c:19:7a:96:5e:ce:01:02:12:06:13:61:
+                    e9:dd:f8:49:8f:2d:fc:e1:6a:52:cd:53:2a:f3:dc:
+                    ac:2f:35:56:f5:cc:1d:1e:4d:3f:6e:72:29:fe:a8:
+                    a0:01:02:80:f1:e2:8e:e9:97:17:de:ac:ef:df:51:
+                    17:0d:f0:66:e2:c7:dc:6d:9c:19:01:00:85:44:9a:
+                    2f:1e:3c:ef:1f:d4:ca:c6:fd:8a:2f:87:55:74:61:
+                    ca:74:0f:bb:dd:31:af:eb:36:f5:68:9b:bd:7a:d3:
+                    be:a6:98:e8:b8:76:39:75:52:ba:12:79:31:f9:d9:
+                    3c:e0:24:da:00:13:aa:17:31:93:d2:ae:ab:f9:cd:
+                    e2:b1:15:22:bc:c1:34:b7:e6:8a:37:21:8c:84:8f:
+                    14:47
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Subject Key Identifier: 
+                01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+            X509v3 Authority Key Identifier: 
+                01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+            X509v3 Basic Constraints: 
+                CA:TRUE
+    Signature Algorithm: sha256WithRSAEncryption
+    Signature Value:
+        19:a5:65:96:6a:4a:7c:12:e8:fd:14:4e:c7:8e:45:df:7b:4f:
+        5e:22:53:b2:5f:15:0b:e7:70:45:95:62:2b:dd:53:bd:27:5f:
+        bd:5b:d5:27:15:11:10:b7:25:9f:0e:df:b6:82:21:76:6e:69:
+        45:aa:c4:60:25:bd:f9:6b:57:cc:aa:c0:80:89:a2:d1:f3:e1:
+        67:d0:06:11:41:7e:f8:af:f4:27:40:e0:0c:a8:09:98:f1:7e:
+        ae:86:ae:96:e2:d7:1d:b7:0d:5d:ef:06:6c:0a:5e:06:57:06:
+        30:1b:37:07:f7:e3:3b:91:71:42:73:3e:bb:58:e5:89:63:84:
+        c2:b3:1b:c5:51:ea:5d:83:98:09:16:7c:28:00:70:56:65:db:
+        b9:ca:83:d7:1a:ea:54:9b:d2:4b:c2:c0:bf:15:c3:87:1f:1e:
+        af:a6:f4:d0:ac:7a:c3:cb:b8:7c:60:ec:a6:6c:77:4a:b2:b6:
+        dd:21:42:92:9c:b9:d9:fe:0f:98:51:0f:fe:ae:86:27:3b:c1:
+        c3:8d:c3:83:b2:a2:20:c8:27:1d:33:13:8e:11:a8:04:72:ad:
+        6a:87:4a:46:7b:06:05:9e:26:b8:d1:08:f3:8c:6c:55:80:8b:
+        dd:e1:26:82:38:1b:9c:f8:21:85:d5:ae:c8:0d:ed:73:f5:ac:
+        cb:a6:4d:93
+-----BEGIN CERTIFICATE-----
+MIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQTAgFw0x
+NTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjELMAkGA1UEBhMCVVMxCzAJ
+BgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1TbWlt
+ZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMIIBIjANBgkqhkiG9w0B
+AQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9AiYIFqrOPrQTuOHS3siZJ+
+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRLH1boMdj0GYSTwk/Jv49x
+sUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqWXs4BAhIGE2Hp3fhJjy38
+4WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX3qzv31EXDfBm4sfcbZwZ
+AQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9etO+ppjouHY5dVK6Enkx
++dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8URwIDAQABo1AwTjAdBgNV
+HQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0jBBgwFoAUAfqHCjtrUZbc
+tqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGaVl
+lmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdfvVvVJxURELclnw7ftoIh
+dm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0DgDKgJmPF+roauluLXHbcN
+Xe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHqXYOYCRZ8KABwVmXbucqD
+1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK23SFCkpy52f4PmFEP/q6G
+JzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI84xsVYCL3eEmgjgbnPgh
+hdWuyA3tc/Wsy6ZNkw==
+-----END CERTIFICATE-----
\ No newline at end of file
diff --git a/crypto/default/cms/testdata/dbx.bin b/crypto/default/cms/testdata/dbx.bin
new file mode 100644
index 0000000..e87e0e3
--- /dev/null
+++ b/crypto/default/cms/testdata/dbx.bin
Binary files differ
diff --git a/crypto/default/cms/testdata/dod_signed.msg b/crypto/default/cms/testdata/dod_signed.msg
new file mode 100644
index 0000000..53f43a8
--- /dev/null
+++ b/crypto/default/cms/testdata/dod_signed.msg
@@ -0,0 +1,102 @@
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Transfer-Encoding: base64
+MIME-Version: 1.0
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAaCAJIAEgaxDb250
+ZW50LVR5cGU6IHRleHQvcGxhaW47DQoJY2hhcnNldD0idXMtYXNjaWkiDQpDb250ZW50LVRyYW5z
+ZmVyLUVuY29kaW5nOiA3Yml0DQoNCkhpIC0NCkkgaGF2ZSBhIHJlc2VydmF0aW9uIGF0IEphY2tz
+b24ncyBmb3Igbm9vbiwgaWYgdGhhdCB3b3JrcyBmb3IgeW91Pw0KDQp2L3IsDQpTdGV2ZQ0KAAAA
+AAAAoIIR1DCCA3MwggJboAMCAQICAQEwDQYJKoZIhvcNAQELBQAwWzELMAkGA1UEBhMCVVMxGDAW
+BgNVBAoTD1UuUy4gR292ZXJubWVudDEMMAoGA1UECxMDRG9EMQwwCgYDVQQLEwNQS0kxFjAUBgNV
+BAMTDURvRCBSb290IENBIDMwHhcNMTIwMzIwMTg0NjQxWhcNMjkxMjMwMTg0NjQxWjBbMQswCQYD
+VQQGEwJVUzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsT
+A1BLSTEWMBQGA1UEAxMNRG9EIFJvb3QgQ0EgMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
+ggEBAKnsFHKK6Etwo9oQA4Sm+6c2DSo6Uha/MBVShgVHIM+qps11xGRu7/FgI8sKZkCutMhoKgBR
+aEk36VkyTZW8QyfpQI06EM4UvEMYofnezOeFdnNeGBojW70/H/LtjRnMA9FApI+nIAJMJ1p5Nvaj
+NyGOAFoGFsrTVZZvMSm7cg7L4khR8tQ3pDXWb+4Xs7EGqwsZhugjbTEbKHhlxd5iUrzBfevuoF1U
+BPuyyyuyI1SRgkzwv7p0QDsMBEWAZ1zF66JXwxp/Ci29f7ncwZmwyAfkDIY2lDolL/J95pc8G5S0
+l1kGyTrkC9nq6fw7czRv/eeY5POhwpBfHPU/LtcZ038CAwEAAaNCMEAwHQYDVR0OBBYEFGyKlKJ3
+sYByHYF6Fqry3M5m7kXAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
+DQEBCwUAA4IBAQCfcaTAtpbSgEOgSOkfdgT5xTytZhhYY5vDtuhoioVaQmYStNLmi4h/h/SY9ajG
+Cckf8Cwf7IK49KVHOMEzK99Mfpq+Cwuxyw98UCgQz4qNoum6rIbX1LGTXyKPlgW0Tgx1kX3T8ueU
+wpQUdk+PDKsQh1gyhQd1hhILXupTtArITISSH+voQYY8uvROQUrRbFhHQcOGWvLu6fKYJ4LqLjbW
++AZegvGgUpNECbrSqRlaWKOoXSBtT2T4MIcbkBNIgc3KkMcNwdSYP47yDldoMxKOmQmx8OT2EPQ2
+8km96qM4yFZBI4Oa36EbNXzrP0Gz9W9LOl6ub5N2mNLxmZ1FxI5yMIIEpTCCA42gAwIBAgIDAVdz
+MA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVybm1lbnQx
+DDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0EtNTEwHhcN
+MTgwODMwMDAwMDAwWhcNMTkwOTMwMjM1OTU5WjB9MQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5T
+LiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsTA1BLSTETMBEGA1UECxMKQ09OVFJB
+Q1RPUjEjMCEGA1UEAxMaSk9ORVMuU1RFUEhFTi5ELjEwMzI5ODczNjkwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQDCDbwtZmSFHPzLARrPvWJgVMt5hdLk8uC0voMxlRJh+HNkfaFq4Hu8
+uPjye2RowlNOctwsci5p5M8LfzPCMf0CHpChcPw23ltPgGfV+GndDNnXNTNARoRDLmIA70OT/HkA
+Rn77P6pLcvxtxfeS3Hj9eGsRDROp1W5Qsb1rpjoS1t8NQTxe7ORUSk4MpA06BK5Eb8jgdW7zPajt
+7aBbmImug4F8nC72SPIC6BxypiSrJQnRzla/Y4qkOcMw5ZRvVR68ylLMJ79q59xxoOZN1zHApIbl
+3xunA5sMpihowkalaICUeqCMhBWm83OYFnD+TIns7OCd83ehHgf9ZplWwMobAgMBAAGjggFMMIIB
+SDAfBgNVHSMEGDAWgBQ17xAibLoSD73CTRnkzn2PBVdcNDA6BgNVHR8EMzAxMC+gLaArhilodHRw
+Oi8vY3JsLmRpc2EubWlsL2NybC9ET0RFTUFJTENBXzUxLmNybDAOBgNVHQ8BAf8EBAMCBSAwFgYD
+VR0gBA8wDTALBglghkgBZQIBCycwHQYDVR0OBBYEFB6Dsdk0XZ4fx36rAZFvPMe7DsyXMGgGCCsG
+AQUFBwEBBFwwWjA2BggrBgEFBQcwAoYqaHR0cDovL2NybC5kaXNhLm1pbC9zaWduL0RPREVNQUlM
+Q0FfNTEuY2VyMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5kaXNhLm1pbDAbBgNVHREEFDASgRBq
+b25lc3N0ZUBucm8ubWlsMBsGA1UdCQQUMBIwEAYIKwYBBQUHCQQxBBMCVVMwDQYJKoZIhvcNAQEL
+BQADggEBAIofdK3Ru3Ec4JeJHaLn1aanUjU3HvtrGaW1noO+faeSkHSJ9ecIE2ndMX4K/CBiFqGG
+NnrGts0Q7gB9omnGqbxJko8WkNFY7wm9TozIg/8dM8wXr9cdxLFzXnY2RvLHz6Bq4fHsg5VCqOvR
+BvaKw4Yeqfl2dhW4fKJiHvj2AZvi23sZ8DDFjTsXBjXDO5CP6f1GP4ARNigyOjGO9Uz/QotdZDIw
+03JLJ4G/4DQoajx1hjLn3tWOBx7Iqkc7g4e0bGRRvPLJRSXU9AGEnuor0oPUnzVXb9SSYxTZJNWM
+mlLcABgvp5FLG8PD1Hm//NSDbzUdjZXIfWppzEaKVrmkOoUwggS8MIIDpKADAgECAgIBJTANBgkq
+hkiG9w0BAQsFADBbMQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYD
+VQQLEwNEb0QxDDAKBgNVBAsTA1BLSTEWMBQGA1UEAxMNRG9EIFJvb3QgQ0EgMzAeFw0xNjExMjIx
+MzQ2NDlaFw0yMjExMjMxMzQ2NDlaMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVy
+bm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0Et
+NTEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCfDs9NqPlQZIyLzCHEak+ygKEYyNVB
+GTUXOn0n+Y+PH+hdzYzAxqErfGkTdgHzqr9J4fUK/mFz09r30e1IX9FocCjd+sWg+dE2g8MuPqKE
+8aYRbAwrLBSA4KdNBwZKU2pHR6y1isxPG2L1soTe5zSGBaJtliW6SIZiDRTKBg9Lj5p8y3NoGWOJ
+RZ5AWuYJoSB4bcFS9f/OA11Kq0b0pfUuOq01kH3WEgOtFQ2iFfnFIvE8ymiMLl3f3W7/QZ8ARd4r
+IDJs83wF1WOonsWXK55UpSAgAH+3+To07eUoSIL5CyuGvBoUjdiZ70l9GxlTz9iG/QvAp4mGjPfn
+nahPR7g/AgMBAAGjggGGMIIBgjAfBgNVHSMEGDAWgBRsipSid7GAch2Behaq8tzOZu5FwDAdBgNV
+HQ4EFgQUNe8QImy6Eg+9wk0Z5M59jwVXXDQwDgYDVR0PAQH/BAQDAgGGMGcGA1UdIARgMF4wCwYJ
+YIZIAWUCAQskMAsGCWCGSAFlAgELJzALBglghkgBZQIBCyowCwYJYIZIAWUCAQs7MAwGCmCGSAFl
+AwIBAw0wDAYKYIZIAWUDAgEDETAMBgpghkgBZQMCAQMnMBIGA1UdEwEB/wQIMAYBAf8CAQAwDAYD
+VR0kBAUwA4ABADA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmRpc2EubWlsL2NybC9ET0RS
+T09UQ0EzLmNybDBsBggrBgEFBQcBAQRgMF4wOgYIKwYBBQUHMAKGLmh0dHA6Ly9jcmwuZGlzYS5t
+aWwvaXNzdWVkdG8vRE9EUk9PVENBM19JVC5wN2MwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmRp
+c2EubWlsMA0GCSqGSIb3DQEBCwUAA4IBAQBjZQocyL3yayU0n2pQFPxk0YqKb6diR8t8GB0/Xfqy
+YlhgNzBQ5fAe3dvVo7LixcjQGQ01Lq07l929ntbj/NgWi4hWciZpryGMOIk2gCLl1goPGQHyI5Zi
+UA/xNnCko3KxsaHgRAdZZDzAO4Vx0Dn/0SWfGlD8knNKBQ4eG0aby+sRCaYkpbQW9sZeEbJ0fHYj
+ka0O8YeqWkXwYUgADkc0BrDWzkN7ZK0RgJNL/L3mdqplp8LFgbrZwZseKvx2nRQpA5RC3zy1OHRM
+CrNwsrPU4kigiRt+nNXpeJe4vRVKhv5bSl5l6pNWr7DudY/Vw2vjiWKDDBWN4XTgvexkoDnjMIIE
+8DCCA9igAwIBAgIDAVdyMA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9V
+LlMuIEdvdmVybm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0Qg
+RU1BSUwgQ0EtNTEwHhcNMTgwODMwMDAwMDAwWhcNMTkwOTMwMjM1OTU5WjB9MQswCQYDVQQGEwJV
+UzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsTA1BLSTET
+MBEGA1UECxMKQ09OVFJBQ1RPUjEjMCEGA1UEAxMaSk9ORVMuU1RFUEhFTi5ELjEwMzI5ODczNjkw
+ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDQjrZkoQP6s3N1YE9epfzobpQJ5R4kAxlh
+5CrHLErReT8PRbVi+rts7yAN089MyHoYZkRBJhwvE/02TgOoB5ggdPE5rfLz0v8q/rh42cQQgWpU
+HEBNTFY/09AVyaS6kzSyQCdElU+enb92uctTOiHEQX/9DsAcoH3ClQPkmmD5kQa2WJXMvSNIXt7d
+ptkSzV0yL/i90Dm5qyesgbHfqFD3gHysCKxPDgvneYvY6AaOQW5PPgNEjdUZE+etNragAOMzhysx
+VSi9qbzL4k44dH92LptNOXDtxCgh1h9w9cFSg/IvNyozHEXxRh+1h0Vv44u9kKBlvJlriw5ob6q2
+C3nvAgMBAAGjggGXMIIBkzAfBgNVHSMEGDAWgBQ17xAibLoSD73CTRnkzn2PBVdcNDA6BgNVHR8E
+MzAxMC+gLaArhilodHRwOi8vY3JsLmRpc2EubWlsL2NybC9ET0RFTUFJTENBXzUxLmNybDAOBgNV
+HQ8BAf8EBAMCBsAwFgYDVR0gBA8wDTALBglghkgBZQIBCyowHQYDVR0OBBYEFMnAEmniFAJzoika
++5iI+4Tv9/CVMGgGCCsGAQUFBwEBBFwwWjA2BggrBgEFBQcwAoYqaHR0cDovL2NybC5kaXNhLm1p
+bC9zaWduL0RPREVNQUlMQ0FfNTEuY2VyMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcC5kaXNhLm1p
+bDA7BgNVHREENDAygRBqb25lc3N0ZUBucm8ubWlsoB4GCisGAQQBgjcUAgOgEAwOMTAzMjk4NzM2
+OUBtaWwwGwYDVR0JBBQwEjAQBggrBgEFBQcJBDEEEwJVUzApBgNVHSUEIjAgBgorBgEEAYI3FAIC
+BggrBgEFBQcDAgYIKwYBBQUHAwQwDQYJKoZIhvcNAQELBQADggEBAA11J3/Y1WW7dKClfouuSLGd
+VdwdldHpUErezAhZbqGkJ8BBDtcDMbaEFrRIzTm1kOrL73X5aY1GE3cimTCMv+aUKKpnhV/6WCm5
++Foxs3Eha07TyCM4txitSHfXK0Gi2c5CqznOxM6FsTF6Ci+8Eg10pxO36DI+6rOmBJuFJJlDLu9R
+ciCCqQVAp0xAtTpGOnS9YOdWhB1eWx7sjsrIv2zwT8B+RrDvLqDjgKvgvSJOLR2Th700X4ZiPgCJ
+bLji5Tv1I/dfm6kpWnUQAqTpMR5kdpBG+4mRJErmUN/ZVHiFxZDZi6a3f7jGpMYET8LofD8zg9d2
+74fh6dSpuNd4q4MxggL/MIIC+wIBATBkMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdv
+dmVybm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwg
+Q0EtNTECAwFXcjAJBgUrDgMCGgUAoIIBcDAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqG
+SIb3DQEJBTEPFw0xOTA4MjgxMzA4NDJaMCMGCSqGSIb3DQEJBDEWBBSduDE4kGE2D+GZUBs2XXRg
+SY3auTAlBgkqhkiG9w0BCQ8xGDCBFTAHBgUrDgMCGjAKBggqhkiG9w0DBzBzBgkrBgEEAYI3EAQx
+ZjBkMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVybm1lbnQxDDAKBgNVBAsMA0Rv
+RDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0EtNTECAwFXczB1BgsqhkiG9w0B
+CRACCzFmoGQwXTELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1UuUy4gR292ZXJubWVudDEMMAoGA1UE
+CwwDRG9EMQwwCgYDVQQLDANQS0kxGDAWBgNVBAMMD0RPRCBFTUFJTCBDQS01MQIDAVdzMA0GCSqG
+SIb3DQEBAQUABIIBAJ3vab3/uSG5kSgHQF9senXwKUyNowgFfVuq8Sr396P8qIvtw3qyn6rj5bq7
+UScglfnIev/zbgI7inVBJARRjR+OYpUyqh9pMSEC7Sr3gWGbuG26K9b7T+Yxqmr6Ad6E1ilFsRaV
+nQiVbA7iQ1joVKZB2oZaz/Ku+ZNX9BmdlNo1YSPOvYMc3I9lHSDTwQs7ff0rK4wTMJcXK5H3acG3
+HvcrwGAxeITgQikwKliGUNr1vfo5nOsz20f3IgJsVOnprkXn6b/BrSX9Zuev34zGKnpvwZBkbBHd
+2Ge1UZutXZf9A2r9XGtcQyzmBiPsWa6Q73ldZ/Wj0NoNFcBJOTU7yrsAAAAAAAA=
\ No newline at end of file
diff --git a/crypto/default/cms/testdata/dod_signed_long.msg b/crypto/default/cms/testdata/dod_signed_long.msg
new file mode 100644
index 0000000..8615354
--- /dev/null
+++ b/crypto/default/cms/testdata/dod_signed_long.msg
@@ -0,0 +1,360 @@
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Transfer-Encoding: base64
+MIME-Version: 1.0
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAaCAJIAEggj1Q29u
+dGVudC1UeXBlOiBtdWx0aXBhcnQvYWx0ZXJuYXRpdmU7DQoJYm91bmRhcnk9Ii0tLS09X05leHRQ
+YXJ0XzAwMF8wMDAwXzAxRDZDMzQzLjMzQUJDQTkwIg0KDQpUaGlzIGlzIGEgbXVsdGlwYXJ0IG1l
+c3NhZ2UgaW4gTUlNRSBmb3JtYXQuDQoNCi0tLS0tLT1fTmV4dFBhcnRfMDAwXzAwMDBfMDFENkMz
+NDMuMzNBQkNBOTANCkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpbjsNCgljaGFyc2V0PSJ1dGYtOCIN
+CkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IDhiaXQNCg0KS291c2hhLA0KDQoNCg0KSGFwcHkg
+VHVya2V5IERheeKAmXMgRXZlIHRvIHlvdSBhbmQgdGhlIGZhbWlseSENCg0KDQoNClRoYW5rcyDi
+gJMgSSBjbG9zZWQgdGhlIGxvb3Agd2l0aCBCaWxsIE1haWRlbiB0byBjbGVhciB0aGF0IHVwLg0K
+DQpGb3IgbmV4dCB3ZWVrIGlmIHlvdSBhcmUgc3RpbGwgZ29vZCB3aXRoIGJyaW5naW5nIGRvd24g
+dGhlIHZpZGVvIGZpbGVzIGZvciANClNwbHVuayAxMDEgYW5kIDEwMiDigJMgQmlsbCBNYWlkZW4g
+d2hvIHNpdHMgYXQgdGhlIGxhYiBhbmQgd2lsbCBiZSBpbiBuZXh0IHdlZWsgDQpoaWdoIHNpZGUg
+d2lsbCBiZSB5b3VyIFBPQy4gIE1wNCBmb3JtYXQgc2hvdWxkIHdvcmsgZmluZS4NCg0KDQoNCkZZ
+SS4gU3RhcnRpbmcgMzAgTm92IHdlIGFyZSBiYWNrIHRvIGEgQmx1ZSBUZWFtL0dvbGQgVGVhbSBy
+b3RhdGlvbi4gIEJpbGwgDQpNYWlkZW4gaXMgYXNzaWduZWQgdG8gQmx1ZSBUZWFtIHNvIGhlIHdp
+bGwgaW4gbmV4dCB3ZWVrIOKAkyBteXNlbGYgYW5kIFJ5YW4gDQpLZW5zZXkgYXJlIG9uIEdvbGQg
+dGVhbSBzbyB3ZSB3aWxsIGJlIG9uIHRoZSBsb3cgc2lkZSBhbGwgbmV4dCB3ZWVrIOKAkyB3aWxs
+IA0KcmVhY2ggb3V0IHRvIHlvdSBuZXh0IHdlZWsgdG8gY2xvc2Ugb24gc29tZSBvZiB0aGUgYWN0
+aW9uIGl0ZW1zIGZyb20gbGFzdCB3ZWVr4oCZcyANCnByZXNlbnRhdGlvbi4NCg0KDQoNCi0gICAg
+ICAgICAgRGFuIFQuDQoNCg0KDQoNCg0KRnJvbTogS291c2hhIFJhdGVnaCA8a3JhdGVnaEBzcGx1
+bmsuY29tPg0KU2VudDogVHVlc2RheSwgTm92ZW1iZXIgMjQsIDIwMjAgOTo0OSBBTQ0KVG86IFRv
+YmF0IERhbmllbCBMIDx0b2JhZGFuaUBucm8ubWlsPg0KQ2M6IERhbiBUb2JhdCA8cS1kYW4tdEBo
+b3RtYWlsLmNvbT47IGRzdGVwaGVuc0BwbGNpbmMudXMNClN1YmplY3Q6IFJlOiBbTm9uLURvRCBT
+b3VyY2VdIEludGVybmFsIFNwbHVuayBTaXRlDQoNCg0KDQpIaSBEYW4sDQoNCg0KDQpCaWxsIHdh
+cyByZWZlcnJpbmcgdG8gdGhlIEVJVEEgd2VicGFnZSwgYnV0IGlmIHlvdSByZWFjaCBvdXQgdG8g
+aGltIHJlOiB0aGUgDQpTcGx1bmsgdHJhaW5pbmcgY29udmVyc2F0aW9uIHdlIGhhZCBoZSBzaG91
+bGQgYmUgYXdhcmUuICBIZSBhc2tlZCB0aGF0IEkgbGV0IA0KeW91IGtub3cgYWJvdXQgdGhlIEVJ
+VEEgd2VicGFnZSBiZWluZyBhbiBvcHRpb24uIFl1cmkgd291bGQgYWxzbyBoYXZlIHRoZSBVUkwg
+DQppZiBuZWVkZWQuDQoNCg0KDQpUaGFua3MsDQoNCg0KDQpLb3VzaGEgUmF0ZWdoDQoNCkN1c3Rv
+bWVyIFN1Y2Nlc3MgTWFuYWdlciwgUHVibGljIFNlY3Rvcg0KDQpTcGx1bmsgSW5jLg0KDQorMSA0
+NDMuNTQwLjI3MjQNCg0KIDxtYWlsdG86a3JhdGVnaEBzcGx1bmsuY29tPiBrb3VzaGFAc3BsdW5r
+LmNvbQ0KDQpXYXNoaW5ndG9uIEQuQy4gfCBBTUVSIHwgQVBBQyB8IEVNRUENCg0KDQoNCiA8aHR0
+cHM6Ly93d3cuc3BsdW5rLmNvbS9lbl91cy9lbnRlcnByaXNlLWRhdGEtcGxhdGZvcm0uaHRtbD4N
+Cg0KVGhpcyBtZXNzYWdlIGlzIGludGVuZGVkIG9ubHkgZm9yIHRoZSBwZXJzb25hbCwgY29uZmlk
+ZW50aWFsLCBhbmQgYXV0aG9yaXplZCANCnVzZSBvZiB0aGUgcmVjaXBpZW50KHMpIG5hbWVkIGFi
+b3ZlLiBJZiB5b3UgYXJlIG5vdCB0aGF0IHBlcnNvbiwgeW91IGFyZSBub3QgDQphdXRob3JpemVk
+IHRvIHJldmlldywgdXNlLCBjb3B5LCBmb3J3YXJkLCBkaXN0cmlidXRlIG9yIG90aGVyd2lzZSBk
+aXNjbG9zZSB0aGUgDQppbmZvcm1hdGlvbiBjb250YWluZWQgaW4gdGhlIG1lc3NhZ2UuDQoNCg0K
+DQoNCg0KDQoNCk9uIE1vbiwgTm92IDIzLCAyMDIwIGF0IDM6NDAgUE0gPHRvYmFkYW5pQG5yby5t
+aWwgPG1haWx0bzp0b2JhZGFuaUBucm8ubWlsPiA+IA0Kd3JvdGU6DQoNCg0KLS0tLS0tPV9OZXh0
+UGFydF8wMDBfMDAwMF8wMUQ2QzM0My4zM0FCQ0E5MA0KQ29udGVudC1UeXBlOiB0ZXh0L2h0bWw7
+DQoJY2hhcnNldD0idXRmLTgiDQpDb250ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBxdW90ZWQtcHJp
+bnRhYmxlDQoNCgSCEAA8aHRtbCB4bWxuczp2PTNEInVybjpzY2hlbWFzLW1pY3Jvc29mdC1jb206
+dm1sIiA9DQp4bWxuczpvPTNEInVybjpzY2hlbWFzLW1pY3Jvc29mdC1jb206b2ZmaWNlOm9mZmlj
+ZSIgPQ0KeG1sbnM6dz0zRCJ1cm46c2NoZW1hcy1taWNyb3NvZnQtY29tOm9mZmljZTp3b3JkIiA9
+DQp4bWxuczptPTNEImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vb2ZmaWNlLzIwMDQvMTIv
+b21tbCIgPQ0KeG1sbnM9M0QiaHR0cDovL3d3dy53My5vcmcvVFIvUkVDLWh0bWw0MCI+PGhlYWQ+
+PG1ldGEgPQ0KaHR0cC1lcXVpdj0zRENvbnRlbnQtVHlwZSBjb250ZW50PTNEInRleHQvaHRtbDsg
+Y2hhcnNldD0zRHV0Zi04Ij48bWV0YSA9DQpuYW1lPTNER2VuZXJhdG9yIGNvbnRlbnQ9M0QiTWlj
+cm9zb2Z0IFdvcmQgMTUgKGZpbHRlcmVkIG1lZGl1bSkiPjwhLS1baWYgPQ0KIW1zb10+PHN0eWxl
+PnZcOioge2JlaGF2aW9yOnVybCgjZGVmYXVsdCNWTUwpO30NCm9cOioge2JlaGF2aW9yOnVybCgj
+ZGVmYXVsdCNWTUwpO30NCndcOioge2JlaGF2aW9yOnVybCgjZGVmYXVsdCNWTUwpO30NCi5zaGFw
+ZSB7YmVoYXZpb3I6dXJsKCNkZWZhdWx0I1ZNTCk7fQ0KPC9zdHlsZT48IVtlbmRpZl0tLT48c3R5
+bGU+PCEtLQ0KLyogRm9udCBEZWZpbml0aW9ucyAqLw0KQGZvbnQtZmFjZQ0KCXtmb250LWZhbWls
+eTpXaW5nZGluZ3M7DQoJcGFub3NlLTE6NSAwIDAgMCAwIDAgMCAwIDAgMDt9DQpAZm9udC1mYWNl
+DQoJe2ZvbnQtZmFtaWx5OiJDYW1icmlhIE1hdGgiOw0KCXBhbm9zZS0xOjIgNCA1IDMgNSA0IDYg
+MyAyIDQ7fQ0KQGZvbnQtZmFjZQ0KCXtmb250LWZhbWlseTpDYWxpYnJpOw0KCXBhbm9zZS0xOjIg
+MTUgNSAyIDIgMiA0IDMgMiA0O30NCi8qIFN0eWxlIERlZmluaXRpb25zICovDQpwLk1zb05vcm1h
+bCwgbGkuTXNvTm9ybWFsLCBkaXYuTXNvTm9ybWFsDQoJe21hcmdpbjowaW47DQoJbWFyZ2luLWJv
+dHRvbTouMDAwMXB0Ow0KCWZvbnQtc2l6ZToxMi4wcHQ7DQoJZm9udC1mYW1pbHk6IlRpbWVzIE5l
+dyBSb21hbiIsc2VyaWY7fQ0KYTpsaW5rLCBzcGFuLk1zb0h5cGVybGluaw0KCXttc28tc3R5bGUt
+cHJpb3JpdHk6OTk7DQoJY29sb3I6Ymx1ZTsNCgl0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lO30N
+CmE6dmlzaXRlZCwgc3Bhbi5Nc29IeXBlcmxpbmtGb2xsb3dlZA0KCXttc28tc3R5bGUtcHJpb3Jp
+dHk6OTk7DQoJY29sb3I6cHVycGxlOw0KCXRleHQtZGVjb3JhdGlvbjp1bmRlcmxpbmU7fQ0KcA0K
+CXttc28tc3R5bGUtcHJpb3JpdHk6OTk7DQoJbXNvLW1hcmdpbi10b3AtYWx0OmF1dG87DQoJbWFy
+Z2luLXJpZ2h0OjBpbjsNCgltc28tbWFyZ2luLWJvdHRvbS1hbHQ6YXV0bzsNCgltYXJnaW4tbGVm
+dDowaW47DQoJZm9udC1zaXplOjEyLjBwdDsNCglmb250LWZhbWlseToiVGltZXMgTmV3IFJvbWFu
+IixzZXJpZjt9DQpwLk1zb0xpc3RQYXJhZ3JhcGgsIGxpLk1zb0xpc3RQYXJhZ3JhcGgsIGRpdi5N
+c29MaXN0UGFyYWdyYXBoDQoJe21zby1zdHlsZS1wcmlvcml0eTozNDsNCgltYXJnaW4tdG9wOjBp
+bjsNCgltYXJnaW4tcmlnaHQ6MGluOw0KCW1hcmdpbi1ib3R0b206MGluOw0KCW1hcmdpbi1sZWZ0
+Oi41aW47DQoJbWFyZ2luLWJvdHRvbTouMDAwMXB0Ow0KCWZvbnQtc2l6ZToxMi4wcHQ7DQoJZm9u
+dC1mYW1pbHk6IlRpbWVzIE5ldyBSb21hbiIsc2VyaWY7fQ0KcC5tc29ub3JtYWwwLCBsaS5tc29u
+b3JtYWwwLCBkaXYubXNvbm9ybWFsMA0KCXttc28tc3R5bGUtbmFtZTptc29ub3JtYWw7DQoJbXNv
+LW1hcmdpbi10b3AtYWx0OmF1dG87DQoJbWFyZ2luLXJpZ2h0OjBpbjsNCgltc28tbWFyZ2luLWJv
+dHRvbS1hbHQ6YXV0bzsNCgltYXJnaW4tbGVmdDowaW47DQoJZm9udC1zaXplOjEyLjBwdDsNCglm
+b250LWZhbWlseToiVGltZXMgTmV3IFJvbWFuIixzZXJpZjt9DQpzcGFuLkVtYWlsU3R5bGUxOQ0K
+CXttc28tc3R5bGUtdHlwZTpwZXJzb25hbC1yZXBseTsNCglmb250LWZhbWlseToiQ2FsaWJyaSIs
+c2Fucy1zZXJpZjsNCgljb2xvcjojMUY0OTdEO30NCi5Nc29DaHBEZWZhdWx0DQoJe21zby1zdHls
+ZS10eXBlOmV4cG9ydC1vbmx5Ow0KCWZvbnQtZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO30N
+CkBwYWdlIFdvcmRTZWN0aW9uMQ0KCXtzaXplOjguNWluIDExLjBpbjsNCgltYXJnaW46MS4waW4g
+MS4waW4gMS4waW4gMS4waW47fQ0KZGl2LldvcmRTZWN0aW9uMQ0KCXtwYWdlOldvcmRTZWN0aW9u
+MTt9DQovKiBMaXN0IERlZmluaXRpb25zICovDQpAbGlzdCBsMA0KCXttc28tbGlzdC1pZDoxMjU4
+NTY1MDU1Ow0KCW1zby1saXN0LXR5cGU6aHlicmlkOw0KCW1zby1saXN0LXRlbXBsYXRlLWlkczot
+MTM5MzAxNjUzNCAtMTE3MzQ2NDMyNiA2NzY5ODY5MSA2NzY5ODY5MyA9DQo2NzY5ODY4OSA2NzY5
+ODY5MSA2NzY5ODY5MyA2NzY5ODY4OSA2NzY5ODY5MSA2NzY5ODY5Mzt9DQpAbGlzdCBsMDpsZXZl
+bDENCgl7bXNvLWxldmVsLXN0YXJ0LWF0OjA7DQoJbXNvLWxldmVsLW51bWJlci1mb3JtYXQ6YnVs
+bGV0Ow0KCW1zby1sZXZlbC10ZXh0Oi07DQoJbXNvLWxldmVsLXRhYi1zdG9wOm5vbmU7DQoJbXNv
+LWxldmVsLW51bWJlci1wb3NpdGlvbjpsZWZ0Ow0KCXRleHQtaW5kZW50Oi0uMjVpbjsNCglmb250
+LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjsNCgltc28tZmFyZWFzdC1mb250LWZhbWlseTpD
+YWxpYnJpO30NCkBsaXN0IGwwOmxldmVsMg0KCXttc28tbGV2ZWwtbnVtYmVyLWZvcm1hdDpidWxs
+ZXQ7DQoJbXNvLWxldmVsLXRleHQ6bzsNCgltc28tbGV2ZWwtdGFiLXN0b3A6bm9uZTsNCgltc28t
+bGV2ZWwtbnVtYmVyLXBvc2l0aW9uOmxlZnQ7DQoJdGV4dC1pbmRlbnQ6LS4yNWluOw0KCWZvbnQt
+ZmFtaWx5OiJDb3VyaWVyIE5ldyI7fQ0KQGxpc3QgbDA6bGV2ZWwzDQoJe21zby1sZXZlbC1udW1i
+ZXItZm9ybWF0OmJ1bGxldDsNCgltc28tbGV2ZWwtdGV4dDo9RUY9ODI9QTc7DQoJbXNvLWxldmVs
+LXRhYi1zdG9wOm5vbmU7DQoJbXNvLWxldmVsLW51bWJlci1wb3NpdGlvbjpsZWZ0Ow0KCXRleHQt
+aW5kZW50Oi0uMjVpbjsNCglmb250LWZhbWlseTpXaW5nZGluZ3M7fQ0KQGxpc3QgbDA6bGV2ZWw0
+DQoJe21zby1sZXZlbC1udW1iZXItZm9ybWF0OmJ1bGxldDsNCgltc28tbGV2ZWwtdGV4dDo9RUY9
+ODI9Qjc7DQoJbXNvLWxldmVsLXRhYi1zdG9wOm5vbmU7DQoJbXNvLWxldmVsLW51bWJlci1wb3Np
+dGlvbjpsZWZ0Ow0KCXRleHQtaW5kZW50Oi0uMjVpbjsNCglmb250LWZhbWlseTpTeW1ib2w7fQ0K
+QGxpc3QgbDA6bGV2ZWw1DQoJe21zby1sZXZlbC1udW1iZXItZm9ybWF0OmJ1bGxldDsNCgltc28t
+bGV2ZWwtdGV4dDpvOw0KCW1zby1sZXZlbC10YWItc3RvcDpub25lOw0KCW1zby1sZXZlbC1udW1i
+ZXItcG9zaXRpb246bGVmdDsNCgl0ZXh0LWluZGVudDotLjI1aW47DQoJZm9udC1mYW1pbHk6IkNv
+dXJpZXIgTmV3Ijt9DQpAbGlzdCBsMDpsZXZlbDYNCgl7bXNvLWxldmVsLW51bWJlci1mb3JtYXQ6
+YnVsbGV0Ow0KCW1zby1sZXZlbC10ZXh0Oj1FRj04Mj1BNzsNCgltc28tbGV2ZWwtdGFiLXN0b3A6
+bm9uZTsNCgltc28tbGV2ZWwtbnVtYmVyLXBvc2l0aW9uOmxlZnQ7DQoJdGV4dC1pbmRlbnQ6LS4y
+NWluOw0KCWZvbnQtZmFtaWx5OldpbmdkaW5nczt9DQpAbGlzdCBsMDpsZXZlbDcNCgl7bXNvLWxl
+dmVsLW51bWJlci1mb3JtYXQ6YnVsbGV0Ow0KCW1zby1sZXZlbC10ZXh0Oj1FRj04Mj1CNzsNCglt
+c28tbGV2ZWwtdGFiLXN0b3A6bm9uZTsNCgltc28tbGV2ZWwtbnVtYmVyLXBvc2l0aW9uOmxlZnQ7
+DQoJdGV4dC1pbmRlbnQ6LS4yNWluOw0KCWZvbnQtZmFtaWx5OlN5bWJvbDt9DQpAbGlzdCBsMDps
+ZXZlbDgNCgl7bXNvLWxldmVsLW51bWJlci1mb3JtYXQ6YnVsbGV0Ow0KCW1zby1sZXZlbC10ZXh0
+Om87DQoJbXNvLWxldmVsLXRhYi1zdG9wOm5vbmU7DQoJbXNvLWxldmVsLW51bWJlci1wb3NpdGlv
+bjpsZWZ0Ow0KCXRleHQtaW5kZW50Oi0uMjVpbjsNCglmb250LWZhbWlseToiQ291cmllciBOZXci
+O30NCkBsaXN0IGwwOmxldmVsOQ0KCXttc28tbGV2ZWwtbnVtYmVyLWZvcm1hdDpidWxsZXQ7DQoJ
+bXNvLWxldmVsLXRleHQ6PUVGPTgyPUE3Ow0KCW1zby1sZXZlbC10YWItc3RvcDpub25lOw0KCW1z
+by1sZXZlBIIQAGwtbnVtYmVyLXBvc2l0aW9uOmxlZnQ7DQoJdGV4dC1pbmRlbnQ6LS4yNWluOw0K
+CWZvbnQtZmFtaWx5OldpbmdkaW5nczt9DQpvbA0KCXttYXJnaW4tYm90dG9tOjBpbjt9DQp1bA0K
+CXttYXJnaW4tYm90dG9tOjBpbjt9DQotLT48L3N0eWxlPjwhLS1baWYgZ3RlIG1zbyA5XT48eG1s
+Pg0KPG86c2hhcGVkZWZhdWx0cyB2OmV4dD0zRCJlZGl0IiBzcGlkbWF4PTNEIjEwMjYiIC8+DQo8
+L3htbD48IVtlbmRpZl0tLT48IS0tW2lmIGd0ZSBtc28gOV0+PHhtbD4NCjxvOnNoYXBlbGF5b3V0
+IHY6ZXh0PTNEImVkaXQiPg0KPG86aWRtYXAgdjpleHQ9M0QiZWRpdCIgZGF0YT0zRCIxIiAvPg0K
+PC9vOnNoYXBlbGF5b3V0PjwveG1sPjwhW2VuZGlmXS0tPjwvaGVhZD48Ym9keSBsYW5nPTNERU4t
+VVMgbGluaz0zRGJsdWUgPQ0Kdmxpbms9M0RwdXJwbGU+PGRpdiBjbGFzcz0zRFdvcmRTZWN0aW9u
+MT48cCBjbGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHlsZT0zRCdmb250LXNpemU6MTEuMHB0
+O2ZvbnQtZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO2NvbG9yOiMxRjQ5N0QnPQ0KPktvdXNo
+YSw8bzpwPjwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29Ob3JtYWw+PHNwYW4gPQ0Kc3R5
+bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtj
+b2xvcjojMUY0OTdEJz0NCj48bzpwPiZuYnNwOzwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RN
+c29Ob3JtYWw+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToi
+Q2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xvcjojMUY0OTdEJz0NCj5IYXBweSBUdXJrZXkgRGF5PUUy
+PTgwPTk5cyBFdmUgdG8geW91IGFuZCB0aGUgPQ0KZmFtaWx5ITxvOnA+PC9vOnA+PC9zcGFuPjwv
+cD48cCBjbGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHlsZT0zRCdmb250LXNpemU6MTEuMHB0
+O2ZvbnQtZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO2NvbG9yOiMxRjQ5N0QnPQ0KPjxvOnA+
+Jm5ic3A7PC9vOnA+PC9zcGFuPjwvcD48cCBjbGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHls
+ZT0zRCdmb250LXNpemU6MTEuMHB0O2ZvbnQtZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO2Nv
+bG9yOiMxRjQ5N0QnPQ0KPlRoYW5rcyA9RTI9ODA9OTMgSSBjbG9zZWQgdGhlIGxvb3Agd2l0aCBC
+aWxsIE1haWRlbiB0byBjbGVhciB0aGF0ID0NCnVwLjxvOnA+PC9vOnA+PC9zcGFuPjwvcD48cCBj
+bGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHlsZT0zRCdmb250LXNpemU6MTEuMHB0O2ZvbnQt
+ZmFtaWx5OiJDYWxpYnJpIixzYW5zLXNlcmlmO2NvbG9yOiMxRjQ5N0QnPQ0KPkZvciBuZXh0IHdl
+ZWsgaWYgeW91IGFyZSBzdGlsbCBnb29kIHdpdGggYnJpbmdpbmcgZG93biB0aGUgdmlkZW8gZmls
+ZXMgPQ0KZm9yIFNwbHVuayAxMDEgYW5kIDEwMiA9RTI9ODA9OTMgQmlsbCBNYWlkZW4gd2hvIHNp
+dHMgYXQgdGhlIGxhYiBhbmQgPQ0Kd2lsbCBiZSBpbiBuZXh0IHdlZWsgaGlnaCBzaWRlIHdpbGwg
+YmUgeW91ciBQT0MuPUMyPUEwIE1wNCBmb3JtYXQgc2hvdWxkID0NCndvcmsgZmluZS48bzpwPjwv
+bzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29Ob3JtYWw+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9u
+dC1zaXplOjExLjBwdDtmb250LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xvcjojMUY0
+OTdEJz0NCj48bzpwPiZuYnNwOzwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29Ob3JtYWw+
+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToiQ2FsaWJyaSIs
+c2Fucy1zZXJpZjtjb2xvcjojMUY0OTdEJz0NCj5GWUkuIFN0YXJ0aW5nIDMwIE5vdiB3ZSBhcmUg
+YmFjayB0byBhIEJsdWUgVGVhbS9Hb2xkIFRlYW0gPQ0Kcm90YXRpb24uPUMyPUEwIEJpbGwgTWFp
+ZGVuIGlzIGFzc2lnbmVkIHRvIEJsdWUgVGVhbSBzbyBoZSB3aWxsIGluIG5leHQgPQ0Kd2VlayA9
+RTI9ODA9OTMgbXlzZWxmIGFuZCBSeWFuIEtlbnNleSBhcmUgb24gR29sZCB0ZWFtIHNvIHdlIHdp
+bGwgYmUgb24gPQ0KdGhlIGxvdyBzaWRlIGFsbCBuZXh0IHdlZWsgPUUyPTgwPTkzIHdpbGwgcmVh
+Y2ggb3V0IHRvIHlvdSBuZXh0IHdlZWsgdG8gPQ0KY2xvc2Ugb24gc29tZSBvZiB0aGUgYWN0aW9u
+IGl0ZW1zIGZyb20gbGFzdCB3ZWVrPUUyPTgwPTk5cyA9DQpwcmVzZW50YXRpb24uPG86cD48L286
+cD48L3NwYW4+PC9wPjxwIGNsYXNzPTNETXNvTm9ybWFsPjxzcGFuID0NCnN0eWxlPTNEJ2ZvbnQt
+c2l6ZToxMS4wcHQ7Zm9udC1mYW1pbHk6IkNhbGlicmkiLHNhbnMtc2VyaWY7Y29sb3I6IzFGNDk3
+RCc9DQo+PG86cD4mbmJzcDs8L286cD48L3NwYW4+PC9wPjxwIGNsYXNzPTNETXNvTGlzdFBhcmFn
+cmFwaCA9DQpzdHlsZT0zRCd0ZXh0LWluZGVudDotLjI1aW47bXNvLWxpc3Q6bDAgbGV2ZWwxIGxm
+bzEnPjwhW2lmID0NCiFzdXBwb3J0TGlzdHNdPjxzcGFuID0NCnN0eWxlPTNEJ2ZvbnQtc2l6ZTox
+MS4wcHQ7Zm9udC1mYW1pbHk6IkNhbGlicmkiLHNhbnMtc2VyaWY7Y29sb3I6IzFGNDk3RCc9DQo+
+PHNwYW4gc3R5bGU9M0QnbXNvLWxpc3Q6SWdub3JlJz4tPHNwYW4gc3R5bGU9M0QnZm9udDo3LjBw
+dCAiVGltZXMgTmV3ID0NClJvbWFuIic+Jm5ic3A7Jm5ic3A7Jm5ic3A7Jm5ic3A7Jm5ic3A7Jm5i
+c3A7Jm5ic3A7Jm5ic3A7Jm5ic3A7ID0NCjwvc3Bhbj48L3NwYW4+PC9zcGFuPjwhW2VuZGlmXT48
+c3BhbiA9DQpzdHlsZT0zRCdmb250LXNpemU6MTEuMHB0O2ZvbnQtZmFtaWx5OiJDYWxpYnJpIixz
+YW5zLXNlcmlmO2NvbG9yOiMxRjQ5N0QnPQ0KPkRhbiBULiA8bzpwPjwvbzpwPjwvc3Bhbj48L3A+
+PHAgY2xhc3M9M0RNc29Ob3JtYWw+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtm
+b250LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xvcjojMUY0OTdEJz0NCj48bzpwPiZu
+YnNwOzwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29Ob3JtYWw+PHNwYW4gPQ0Kc3R5bGU9
+M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xv
+cjojMUY0OTdEJz0NCj48bzpwPiZuYnNwOzwvbzpwPjwvc3Bhbj48L3A+PHAgY2xhc3M9M0RNc29O
+b3JtYWw+PGI+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWlseToi
+Q2FsaWJyaSIsc2Fucy1zZXJpZic+RnJvbTo8L3NwYW4+PD0NCi9iPjxzcGFuIHN0eWxlPTNEJ2Zv
+bnQtc2l6ZToxMS4wcHQ7Zm9udC1mYW1pbHk6IkNhbGlicmkiLHNhbnMtc2VyaWYnPiA9DQpLb3Vz
+aGEgUmF0ZWdoICZsdDtrcmF0ZWdoQHNwbHVuay5jb20mZ3Q7IDxicj48Yj5TZW50OjwvYj4gVHVl
+c2RheSwgPQ0KTm92ZW1iZXIgMjQsIDIwMjAgOTo0OSBBTTxicj48Yj5Ubzo8L2I+IFRvYmF0IERh
+bmllbCBMID0NCiZsdDt0b2JhZGFuaUBucm8ubWlsJmd0Ozxicj48Yj5DYzo8L2I+IERhbiBUb2Jh
+dCA9DQombHQ7cS1kYW4tdEBob3RtYWlsLmNvbSZndDs7IGRzdGVwaGVuc0BwbGNpbmMudXM8YnI+
+PGI+U3ViamVjdDo8L2I+IFJlOiA9DQpbTm9uLURvRCBTb3VyY2VdIEludGVybmFsIFNwbHVuayBT
+aXRlPG86cD48L286cD48L3NwYW4+PC9wPjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPjxvOnA+Jm5i
+c3A7PC9vOnA+PC9wPjxkaXY+PGRpdj48cCBjbGFzcz0zRE1zb05vcm1hbD5IaSA9DQpEYW4sPG86
+cD48L286cD48L3A+PC9kaXY+PGRpdj48cCA9DQpjbGFzcz0zRE1zb05vcm1hbD48bzpwPiZuYnNw
+OzwvbzpwPjwvcD48L2Rpdj48ZGl2PjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPkJpbGwgd2FzIHJl
+ZmVycmluZyB0byB0aGUgRUlUQSB3ZWJwYWdlLCBidXQgaWYgeW91ID0NCnJlYWNoIG91dCB0byBo
+aW0gcmU6IHRoZSZuYnNwO1NwbHVuayB0cmFpbmluZyBjb252ZXJzYXRpb24gd2UgaGFkIGhlID0N
+CnNob3VsZCBiZSBhd2FyZS4mbmJzcDsgSGUgYXNrZWQgdGhhdCBJIGxldCB5b3Uga25vdyBhYm91
+dCB0aGUgRUlUQSA9DQp3ZWJwYWdlIGJlaW5nIGFuIG9wdGlvbi4gWXVyaSB3b3VsZCBhbHNvIGhh
+dmUgdGhlIFVSTCBpZiA9DQpuZWVkZWQuPG86cD48L286cD48L3A+PC9kaXY+PGRpdj48cCA9DQpj
+bGEEggxXc3M9M0RNc29Ob3JtYWw+PG86cD4mbmJzcDs8L286cD48L3A+PC9kaXY+PGRpdj48cCA9
+DQpjbGFzcz0zRE1zb05vcm1hbD48c3BhbiA9DQpzdHlsZT0zRCdmb250LWZhbWlseToiQXJpYWwi
+LHNhbnMtc2VyaWY7Y29sb3I6IzNCM0IzQic+VGhhbmtzLDwvc3Bhbj48bzpwPQ0KPjwvbzpwPjwv
+cD48L2Rpdj48ZGl2PjxkaXY+PGRpdj48cCA9DQpjbGFzcz0zRE1zb05vcm1hbD48bzpwPiZuYnNw
+OzwvbzpwPjwvcD48cCBjbGFzcz0zRE1zb05vcm1hbD48Yj48c3BhbiA9DQpzdHlsZT0zRCdmb250
+LWZhbWlseToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6IzNCM0IzQic+S291c2hhID0NClJhdGVn
+aDwvc3Bhbj48L2I+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1zaXplOjExLjBwdDtmb250LWZhbWls
+eToiQ2FsaWJyaSIsc2Fucy1zZXJpZjtjb2xvcjpibGFjayc+PD0NCm86cD48L286cD48L3NwYW4+
+PC9wPjxwIHN0eWxlPTNEJ21hcmdpbjowaW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48c3BhbiA9
+DQpzdHlsZT0zRCdmb250LWZhbWlseToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6IzMxMzEzMSc+
+Q3VzdG9tZXIgU3VjY2VzcyA9DQpNYW5hZ2VyLCBQdWJsaWMgU2VjdG9yPC9zcGFuPjxzcGFuID0N
+CnN0eWxlPTNEJ2NvbG9yOmJsYWNrJz48bzpwPjwvbzpwPjwvc3Bhbj48L3A+PHAgPQ0Kc3R5bGU9
+M0QnbWFyZ2luOjBpbjttYXJnaW4tYm90dG9tOi4wMDAxcHQnPjxzcGFuID0NCnN0eWxlPTNEJ2Zv
+bnQtc2l6ZTo5LjVwdDtmb250LWZhbWlseToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6YmxhY2sn
+PlNwbHU9DQpuayBJbmMuPC9zcGFuPjxvOnA+PC9vOnA+PC9wPjxwID0NCnN0eWxlPTNEJ21hcmdp
+bjowaW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48c3BhbiA9DQpzdHlsZT0zRCdmb250LWZhbWls
+eToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6IzMxMzEzMSc+KzEgPQ0KNDQzLjU0MC4yNzI0Jm5i
+c3A7PC9zcGFuPjxzcGFuID0NCnN0eWxlPTNEJ2NvbG9yOmJsYWNrJz48bzpwPjwvbzpwPjwvc3Bh
+bj48L3A+PHAgPQ0Kc3R5bGU9M0QnbWFyZ2luOjBpbjttYXJnaW4tYm90dG9tOi4wMDAxcHQnPjxz
+cGFuID0NCnN0eWxlPTNEJ2NvbG9yOmJsYWNrJz48YSBocmVmPTNEIm1haWx0bzprcmF0ZWdoQHNw
+bHVuay5jb20iID0NCnRhcmdldD0zRCJfYmxhbmsiPjxzcGFuID0NCnN0eWxlPTNEJ2ZvbnQtZmFt
+aWx5OiJBcmlhbCIsc2Fucy1zZXJpZjtjb2xvcjojMDU2M0MxJz5rb3VzaGFAc3BsdW5rLmNvbTw9
+DQovc3Bhbj48L2E+PG86cD48L286cD48L3NwYW4+PC9wPjxwID0NCnN0eWxlPTNEJ21hcmdpbjow
+aW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48Yj48c3BhbiA9DQpzdHlsZT0zRCdmb250LWZhbWls
+eToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6YmxhY2snPldhc2hpbmd0b24gPQ0KRC5DLjwvc3Bh
+bj48L2I+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1mYW1pbHk6IkFyaWFsIixzYW5zLXNlcmlmO2Nv
+bG9yOmJsYWNrJz4mbmJzcDs8L3NwYW4+PHNwYW4gPQ0Kc3R5bGU9M0QnZm9udC1mYW1pbHk6IkFy
+aWFsIixzYW5zLXNlcmlmO2NvbG9yOiNBNUE1QTUnPnwmbmJzcDtBTUVSIHwgPQ0KQVBBQyB8IEVN
+RUE8L3NwYW4+PHNwYW4gPQ0Kc3R5bGU9M0QnY29sb3I6YmxhY2snPjxvOnA+PC9vOnA+PC9zcGFu
+PjwvcD48ZGl2PjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPjxvOnA+Jm5ic3A7PC9vOnA+PC9wPjwv
+ZGl2PjxwID0NCnN0eWxlPTNEJ21hcmdpbjowaW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48YSA9
+DQpocmVmPTNEImh0dHBzOi8vd3d3LnNwbHVuay5jb20vZW5fdXMvZW50ZXJwcmlzZS1kYXRhLXBs
+YXRmb3JtLmh0bWwiID0NCnRhcmdldD0zRCJfYmxhbmsiPjxzcGFuID0NCnN0eWxlPTNEJ2ZvbnQt
+c2l6ZTo3LjBwdDtmb250LWZhbWlseToiQXJpYWwiLHNhbnMtc2VyaWY7Y29sb3I6IzExNTVDQzti
+b3I9DQpkZXI6bm9uZSB3aW5kb3d0ZXh0IDEuMHB0O3BhZGRpbmc6MGluO3RleHQtZGVjb3JhdGlv
+bjpub25lJz48aW1nID0NCmJvcmRlcj0zRDAgd2lkdGg9M0Q0MzIgaGVpZ2h0PTNEOTMgc3R5bGU9
+M0Qnd2lkdGg6NC41aW47aGVpZ2h0Oi45Njg3aW4nID0NCmlkPTNEIl94MDAwMF9pMTAyNSIgPQ0K
+c3JjPTNEImh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9qV0pES0lkREpWeVN4RTVB
+emRnRFE1eEdEamd4Rk0tUD0NCnE4dm4xWlYwcFdwMGtCR00zQThqVndNd00tVGtLbGNqUy1nRDFp
+M2dtX0FsYWdia0lDbEg1aTU4dEdKOHVhM0JJMGRTU0ZRTWg9DQpITnAzUjl2UXF5LV90d0h6RDBO
+MVlmdHhDTkNMZG5KIj48L3NwYW4+PC9hPjxvOnA+PC9vOnA+PC9wPjxwID0NCnN0eWxlPTNEJ21h
+cmdpbjowaW47bWFyZ2luLWJvdHRvbTouMDAwMXB0Jz48c3BhbiA9DQpzdHlsZT0zRCdmb250LXNp
+emU6Ny4wcHQ7Zm9udC1mYW1pbHk6IkFyaWFsIixzYW5zLXNlcmlmO2NvbG9yOiNBNUE1QTUnPlRo
+PQ0KaXMgbWVzc2FnZSBpcyBpbnRlbmRlZCBvbmx5IGZvciB0aGUgcGVyc29uYWwsIGNvbmZpZGVu
+dGlhbCwgYW5kID0NCmF1dGhvcml6ZWQgdXNlIG9mIHRoZSByZWNpcGllbnQocykgbmFtZWQgYWJv
+dmUuIElmIHlvdSBhcmUgbm90IHRoYXQgPQ0KcGVyc29uLCB5b3UgYXJlIG5vdCBhdXRob3JpemVk
+IHRvIHJldmlldywgdXNlLCBjb3B5LCBmb3J3YXJkLCBkaXN0cmlidXRlID0NCm9yIG90aGVyd2lz
+ZSBkaXNjbG9zZSB0aGUgaW5mb3JtYXRpb24gY29udGFpbmVkIGluIHRoZSA9DQptZXNzYWdlLjwv
+c3Bhbj48bzpwPjwvbzpwPjwvcD48ZGl2PjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPjxvOnA+Jm5i
+c3A7PC9vOnA+PC9wPjwvZGl2PjwvZGl2PjwvZGl2PjwvZGl2PjxwID0NCmNsYXNzPTNETXNvTm9y
+bWFsPjxvOnA+Jm5ic3A7PC9vOnA+PC9wPjwvZGl2PjxwID0NCmNsYXNzPTNETXNvTm9ybWFsPjxv
+OnA+Jm5ic3A7PC9vOnA+PC9wPjxkaXY+PGRpdj48cCBjbGFzcz0zRE1zb05vcm1hbD5PbiA9DQpN
+b24sIE5vdiAyMywgMjAyMCBhdCAzOjQwIFBNICZsdDs8YSA9DQpocmVmPTNEIm1haWx0bzp0b2Jh
+ZGFuaUBucm8ubWlsIj50b2JhZGFuaUBucm8ubWlsPC9hPiZndDsgPQ0Kd3JvdGU6PG86cD48L286
+cD48L3A+PC9kaXY+PC9kaXY+PC9kaXY+PC9ib2R5PjwvaHRtbD4NCi0tLS0tLT1fTmV4dFBhcnRf
+MDAwXzAwMDBfMDFENkMzNDMuMzNBQkNBOTAtLQ0KAAAAAAAAoIIWkjCCA3MwggJboAMCAQICAQEw
+DQYJKoZIhvcNAQELBQAwWzELMAkGA1UEBhMCVVMxGDAWBgNVBAoTD1UuUy4gR292ZXJubWVudDEM
+MAoGA1UECxMDRG9EMQwwCgYDVQQLEwNQS0kxFjAUBgNVBAMTDURvRCBSb290IENBIDMwHhcNMTIw
+MzIwMTg0NjQxWhcNMjkxMjMwMTg0NjQxWjBbMQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5TLiBH
+b3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsTA1BLSTEWMBQGA1UEAxMNRG9EIFJvb3Qg
+Q0EgMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKnsFHKK6Etwo9oQA4Sm+6c2DSo6
+Uha/MBVShgVHIM+qps11xGRu7/FgI8sKZkCutMhoKgBRaEk36VkyTZW8QyfpQI06EM4UvEMYofne
+zOeFdnNeGBojW70/H/LtjRnMA9FApI+nIAJMJ1p5NvajNyGOAFoGFsrTVZZvMSm7cg7L4khR8tQ3
+pDXWb+4Xs7EGqwsZhugjbTEbKHhlxd5iUrzBfevuoF1UBPuyyyuyI1SRgkzwv7p0QDsMBEWAZ1zF
+66JXwxp/Ci29f7ncwZmwyAfkDIY2lDolL/J95pc8G5S0l1kGyTrkC9nq6fw7czRv/eeY5POhwpBf
+HPU/LtcZ038CAwEAAaNCMEAwHQYDVR0OBBYEFGyKlKJ3sYByHYF6Fqry3M5m7kXAMA4GA1UdDwEB
+/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCfcaTAtpbSgEOgSOkf
+dgT5xTytZhhYY5vDtuhoioVaQmYStNLmi4h/h/SY9ajGCckf8Cwf7IK49KVHOMEzK99Mfpq+Cwux
+yw98UCgQz4qNoum6rIbX1LGTXyKPlgW0Tgx1kX3T8ueUwpQUdk+PDKsQh1gyhQd1hhILXupTtArI
+TISSH+voQYY8uvROQUrRbFhHQcOGWvLu6fKYJ4LqLjbW+AZegvGgUpNECbrSqRlaWKOoXSBtT2T4
+MIcbkBNIgc3KkMcNwdSYP47yDldoMxKOmQmx8OT2EPQ28km96qM4yFZBI4Oa36EbNXzrP0Gz9W9L
+Ol6ub5N2mNLxmZ1FxI5yMIIEpDCCA4ygAwIBAgIDHh1EMA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNV
+BAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVybm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwD
+UEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0EtNDkwHhcNMTkxMjExMDAwMDAwWhcNMjIxMTExMjM1
+OTU5WjB8MQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNE
+b0QxDDAKBgNVBAsTA1BLSTETMBEGA1UECxMKQ09OVFJBQ1RPUjEiMCAGA1UEAxMZVE9CQVQuREFO
+SUVMLkwuMTA0NzU3NDUxNjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKWAxVnCZxLl
+Cuf5epS+D3BqK5iVFjmRcB+SNVIreNsvq8qEn699p+zKyQHcjs668YocaVDS0q1sHVWHJEjaY0zh
+C+aemyER5ErOFfk643fPE9l7+98EnYXqAwhGZT4JBcWPL9/lCnF2wMbWkXPPAOVLStxjKGW/24cj
+owhGnNlFOo7++jb1OtZzzr2XxRE3dy6BMo1jgGK1cQZaCEDe5NpyBN6QYocVoaAxW4pyTHRM/rck
++/zjmvI8tsxgylvRUAUWbohrcS3qv2X9prblvaaTU4h7Z7R3ngTouMxRUyDXtT66JV3A7/NTDikr
+5KLZibIui2xUTXH4gb4hNsRxCaUCAwEAAaOCAUwwggFIMB8GA1UdIwQYMBaAFGlEHxqVTqUaf4g6
+zHsSOSwfzxoxMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwuZGlzYS5taWwvY3JsL0RPREVN
+QUlMQ0FfNDkuY3JsMA4GA1UdDwEB/wQEAwIFIDAWBgNVHSAEDzANMAsGCWCGSAFlAgELJzAdBgNV
+HQ4EFgQUYRFS5Ebn6PC0KadICbTIhjZwIV0waAYIKwYBBQUHAQEEXDBaMDYGCCsGAQUFBzAChipo
+dHRwOi8vY3JsLmRpc2EubWlsL3NpZ24vRE9ERU1BSUxDQV80OS5jZXIwIAYIKwYBBQUHMAGGFGh0
+dHA6Ly9vY3NwLmRpc2EubWlsMBsGA1UdEQQUMBKBEHRvYmFkYW5pQG5yby5taWwwGwYDVR0JBBQw
+EjAQBggrBgEFBQcJBDEEEwJVUzANBgkqhkiG9w0BAQsFAAOCAQEARPxs6JdWoXUiLU7Q5XW8vwF3
+GBCpOxkUaEpmMmrWyPKMhsnhzZZcJVTcf+W8kHD4og4Q5T4biGHh/NuJz4IVzyEXT0rAiZ+hSNL+
+bGO7pJQ/kF6NvQYNUJVA7oDsrcNUmX5yZ0STwsjcuxC2sPjbpU5TxKx3Z4WYd6jatOHk7Hdord7b
+k/CmrAdpaq5mSZFswqJ5Y4OHdD1tQ6R9zC4H4dLappPGgEbMynWpmEFzRKkHJlkjvnCL1TXr1qgL
+JFVpU62RUqylG2bKVVkGm5dGBNL7616mvnz3qDx85hwcV3mBXjO8+793+cFREAokDyuCT/YD1OTK
+DsBwJR8/0p/EcTCCBLwwggOkoAMCAQICAgEjMA0GCSqGSIb3DQEBCwUAMFsxCzAJBgNVBAYTAlVT
+MRgwFgYDVQQKEw9VLlMuIEdvdmVybm1lbnQxDDAKBgNVBAsTA0RvRDEMMAoGA1UECxMDUEtJMRYw
+FAYDVQQDEw1Eb0QgUm9vdCBDQSAzMB4XDTE2MTEyMjEzNDMxNFoXDTIyMTEyMzEzNDMxNFowXTEL
+MAkGA1UEBhMCVVMxGDAWBgNVBAoMD1UuUy4gR292ZXJubWVudDEMMAoGA1UECwwDRG9EMQwwCgYD
+VQQLDANQS0kxGDAWBgNVBAMMD0RPRCBFTUFJTCBDQS00OTCCASIwDQYJKoZIhvcNAQEBBQADggEP
+ADCCAQoCggEBALrNt8bTgG3y8T4fjkvFNzf3O2bJ0zWRpIuio5xIOBFR51CsAQdaVi3kG2U1adNX
+R1pcUSsrwDZ5PvKMzPLbnKMMdKun6OSf2LXg/ahxQKFoyUKUQeh759r7x71G7u4gGOT3pdAegNDT
+f5SlturU5YpujZFdTLgnL6Qhq8B9H9kzgs5A3+yD6kA7y1joz0D/Z5jTBp0vqgrxeRdIhhV0x9TQ
+3iPBD7yjAUBPi4CeDwBfHhXQ6NJgxOoi3zwCFwc32FHRZDEt3laSTlj8zhGHkS3FkJDkb3M38TdK
+G8Uh90xqASwd7mxCzqIqoLExe6yyhNSss1q2qNebqGH9vTvY2rUCAwEAAaOCAYYwggGCMB8GA1Ud
+IwQYMBaAFGyKlKJ3sYByHYF6Fqry3M5m7kXAMB0GA1UdDgQWBBRpRB8alU6lGn+IOsx7EjksH88a
+MTAOBgNVHQ8BAf8EBAMCAYYwZwYDVR0gBGAwXjALBglghkgBZQIBCyQwCwYJYIZIAWUCAQsnMAsG
+CWCGSAFlAgELKjALBglghkgBZQIBCzswDAYKYIZIAWUDAgEDDTAMBgpghkgBZQMCAQMRMAwGCmCG
+SAFlAwIBAycwEgYDVR0TAQH/BAgwBgEB/wIBADAMBgNVHSQEBTADgAEAMDcGA1UdHwQwMC4wLKAq
+oCiGJmh0dHA6Ly9jcmwuZGlzYS5taWwvY3JsL0RPRFJPT1RDQTMuY3JsMGwGCCsGAQUFBwEBBGAw
+XjA6BggrBgEFBQcwAoYuaHR0cDovL2NybC5kaXNhLm1pbC9pc3N1ZWR0by9ET0RST09UQ0EzX0lU
+LnA3YzAgBggrBgEFBQcwAYYUaHR0cDovL29jc3AuZGlzYS5taWwwDQYJKoZIhvcNAQELBQADggEB
+AFwzZGg9hsHuGaFpJcLwL73qDmCubPHyW42ToyNldbWYyD+XncPGErywxk6rGg5WqNvIQlYaDlNP
+ldt+KdAgBijSiA+lgkeOrO7gAC2mln3eGhw839tKmLJzK5dHigr+hbFLSgZCGCQY8aaGiKbnUQp0
+pkJMcHiswhgrY1gk9jBkia6NkNHkFkw8ZyV/f7/M+3WfHXWTtc4KFNcZwMfS8Xa1Zeh9lXAde8+r
+dneU6kizPzp7kh0+y1qmG1SREX9/A1NFkkO1TqdsdUpCJyaqXsL4vtK5f8ofe1KA2bkvxlJFSDBW
+6SZG3GnYhBgSDRK0ZUeb0GQ3rAaRqlMTWLiCCWwwggS8MIIDpKADAgECAgIBJTANBgkqhkiG9w0B
+AQsFADBbMQswCQYDVQQGEwJVUzEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNE
+b0QxDDAKBgNVBAsTA1BLSTEWMBQGA1UEAxMNRG9EIFJvb3QgQ0EgMzAeFw0xNjExMjIxMzQ2NDla
+Fw0yMjExMjMxMzQ2NDlaMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdvdmVybm1lbnQx
+DDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwgQ0EtNTEwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCfDs9NqPlQZIyLzCHEak+ygKEYyNVBGTUXOn0n
++Y+PH+hdzYzAxqErfGkTdgHzqr9J4fUK/mFz09r30e1IX9FocCjd+sWg+dE2g8MuPqKE8aYRbAwr
+LBSA4KdNBwZKU2pHR6y1isxPG2L1soTe5zSGBaJtliW6SIZiDRTKBg9Lj5p8y3NoGWOJRZ5AWuYJ
+oSB4bcFS9f/OA11Kq0b0pfUuOq01kH3WEgOtFQ2iFfnFIvE8ymiMLl3f3W7/QZ8ARd4rIDJs83wF
+1WOonsWXK55UpSAgAH+3+To07eUoSIL5CyuGvBoUjdiZ70l9GxlTz9iG/QvAp4mGjPfnnahPR7g/
+AgMBAAGjggGGMIIBgjAfBgNVHSMEGDAWgBRsipSid7GAch2Behaq8tzOZu5FwDAdBgNVHQ4EFgQU
+Ne8QImy6Eg+9wk0Z5M59jwVXXDQwDgYDVR0PAQH/BAQDAgGGMGcGA1UdIARgMF4wCwYJYIZIAWUC
+AQskMAsGCWCGSAFlAgELJzALBglghkgBZQIBCyowCwYJYIZIAWUCAQs7MAwGCmCGSAFlAwIBAw0w
+DAYKYIZIAWUDAgEDETAMBgpghkgBZQMCAQMnMBIGA1UdEwEB/wQIMAYBAf8CAQAwDAYDVR0kBAUw
+A4ABADA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmRpc2EubWlsL2NybC9ET0RST09UQ0Ez
+LmNybDBsBggrBgEFBQcBAQRgMF4wOgYIKwYBBQUHMAKGLmh0dHA6Ly9jcmwuZGlzYS5taWwvaXNz
+dWVkdG8vRE9EUk9PVENBM19JVC5wN2MwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmRpc2EubWls
+MA0GCSqGSIb3DQEBCwUAA4IBAQBjZQocyL3yayU0n2pQFPxk0YqKb6diR8t8GB0/XfqyYlhgNzBQ
+5fAe3dvVo7LixcjQGQ01Lq07l929ntbj/NgWi4hWciZpryGMOIk2gCLl1goPGQHyI5ZiUA/xNnCk
+o3KxsaHgRAdZZDzAO4Vx0Dn/0SWfGlD8knNKBQ4eG0aby+sRCaYkpbQW9sZeEbJ0fHYjka0O8Yeq
+WkXwYUgADkc0BrDWzkN7ZK0RgJNL/L3mdqplp8LFgbrZwZseKvx2nRQpA5RC3zy1OHRMCrNwsrPU
+4kigiRt+nNXpeJe4vRVKhv5bSl5l6pNWr7DudY/Vw2vjiWKDDBWN4XTgvexkoDnjMIIE7zCCA9eg
+AwIBAgIDG+g3MA0GCSqGSIb3DQEBCwUAMF0xCzAJBgNVBAYTAlVTMRgwFgYDVQQKDA9VLlMuIEdv
+dmVybm1lbnQxDDAKBgNVBAsMA0RvRDEMMAoGA1UECwwDUEtJMRgwFgYDVQQDDA9ET0QgRU1BSUwg
+Q0EtNTEwHhcNMTkxMjExMDAwMDAwWhcNMjIxMTExMjM1OTU5WjB8MQswCQYDVQQGEwJVUzEYMBYG
+A1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsTA1BLSTETMBEGA1UE
+CxMKQ09OVFJBQ1RPUjEiMCAGA1UEAxMZVE9CQVQuREFOSUVMLkwuMTA0NzU3NDUxNjCCASIwDQYJ
+KoZIhvcNAQEBBQADggEPADCCAQoCggEBAI4i8CP3MZv76KFIMiqly0bU8kHpFef8EF/xyMcmacYR
+pVs0aLfwMDPMIsvEnJNRu3kIaMNkXJI8gKujrXI8n8woVmtFJc8kbrc96twfP3MYJcw9LEs8Q5fU
+4Sf8x/Y6eftTWo7sP8TrxoZjIihxQGQ7HcrHD78qklLENZWllVabEEHDJBDTwK3zI7b6nnLOBtVX
+Z418tXMU89iVz8NK8xzQnLymjbFjWnyg8sJ7MgejiJ9Z8T0j5qIrat1IfFITzgLKMFw/BTkEyMsK
+Jzh4DoQ5XjXgf7BPZvmsWrFPIAbDJ0m1zGN81p37gvvSKpOK6+yn7IIl5tEa7XqrUOHJ3p0CAwEA
+AaOCAZcwggGTMB8GA1UdIwQYMBaAFDXvECJsuhIPvcJNGeTOfY8FV1w0MDoGA1UdHwQzMDEwL6At
+oCuGKWh0dHA6Ly9jcmwuZGlzYS5taWwvY3JsL0RPREVNQUlMQ0FfNTEuY3JsMA4GA1UdDwEB/wQE
+AwIGwDAWBgNVHSAEDzANMAsGCWCGSAFlAgELKjAdBgNVHQ4EFgQUXK3htFNLKN0dEIJBVGOG0gwU
+zoswaAYIKwYBBQUHAQEEXDBaMDYGCCsGAQUFBzAChipodHRwOi8vY3JsLmRpc2EubWlsL3NpZ24v
+RE9ERU1BSUxDQV81MS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3NwLmRpc2EubWlsMDsGA1Ud
+EQQ0MDKBEHRvYmFkYW5pQG5yby5taWygHgYKKwYBBAGCNxQCA6AQDA4xMDQ3NTc0NTE2QG1pbDAb
+BgNVHQkEFDASMBAGCCsGAQUFBwkEMQQTAlVTMCkGA1UdJQQiMCAGCisGAQQBgjcUAgIGCCsGAQUF
+BwMCBggrBgEFBQcDBDANBgkqhkiG9w0BAQsFAAOCAQEAHn8EjP1Iijs6XwM2ENQrCK97qbL4YaGG
+LW5ir3H8N+nV55qEMyPKltKdBT15erEQ07UkfY64ESl0Y2t60AMxMZwDd4AT8IxmJtfeWgGOjncI
+OO7v5anLPhKecQ7fXrTziOf5byewcdEgWFkAm3XimefHtXNL+avpbxqKMn1cIBPiyj/OZlmcsDI8
+0y/m6Hzt7ZVkk15K7kaoJziYNnX+JmHOq1zNSujhozR+i6k3hGp1FAaECYwtzxkHVEWS1LYb31Ua
+26Zuy7A0iv5KldGPqghFzj2F8CB3rCqSc45MYRhnbtq6XSEtFur2QYtLBIrm0iFnstRe9KE9sjy3
+B7UsQTGCAv8wggL7AgEBMGQwXTELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1UuUy4gR292ZXJubWVu
+dDEMMAoGA1UECwwDRG9EMQwwCgYDVQQLDANQS0kxGDAWBgNVBAMMD0RPRCBFTUFJTCBDQS01MQID
+G+g3MAkGBSsOAwIaBQCgggFwMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkF
+MQ8XDTIwMTEyNTIwNTQwM1owIwYJKoZIhvcNAQkEMRYEFObjEsj6iAKkGYHlXiO6xhwsr4cSMCUG
+CSqGSIb3DQEJDzEYMIEVMAcGBSsOAwIaMAoGCCqGSIb3DQMHMHMGCSsGAQQBgjcQBDFmMGQwXTEL
+MAkGA1UEBhMCVVMxGDAWBgNVBAoMD1UuUy4gR292ZXJubWVudDEMMAoGA1UECwwDRG9EMQwwCgYD
+VQQLDANQS0kxGDAWBgNVBAMMD0RPRCBFTUFJTCBDQS00OQIDHh1EMHUGCyqGSIb3DQEJEAILMWag
+ZDBdMQswCQYDVQQGEwJVUzEYMBYGA1UECgwPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLDANEb0Qx
+DDAKBgNVBAsMA1BLSTEYMBYGA1UEAwwPRE9EIEVNQUlMIENBLTQ5AgMeHUQwDQYJKoZIhvcNAQEB
+BQAEggEAOPK1FRTU6nMjAjZVc14qrq6poU7FLjqh5l+DVO16VYerobEakQiSlOG8ewji9FuORlqF
+fZ/SK8FnYQeqPp/281X3XDiXs2rUu+0PHnyeEUS/KQt0M/a4Ezy6ZBGjb+xjyVDY63+Pw1/Ua72r
+LvFTVCkUgzqSHIJtBhf2Dkr4L/DRPt9v1dYJayDPHV82AO5hfUV4uQl0YBNduSKpnh6Pz8JS6bj+
+JQnIG38xd+9BX03wtCt9wewxlp5ticU9+rQxzQGnmGPrRKGfmd5lqGA4W3jNzM9gdj5/HlczsYtm
+U/SgNdtWdcaZ9nyDkEZxmcjzOg6hPAdH8O40niYsPE1XhwAAAAAAAA==
\ No newline at end of file
diff --git a/crypto/default/cms/testdata/encrypted-aes-128-cbc.msg b/crypto/default/cms/testdata/encrypted-aes-128-cbc.msg
new file mode 100644
index 0000000..c4f73c1
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-aes-128-cbc.msg
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICHAYJKoZIhvcNAQcDoIICDTCCAgkCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw
+DQYJKoZIhvcNAQEBBQAEggEAChuX9o6tBsTgR2Lqq+n6ZN25Cxa1u50enJKvlXXs
+5BqiQGDTTHpcRV+xN2DP0py4Hw8iyKyeP5HNzPk316td8AZ59PeJkVPI992K6PHn
+qg0IT4NqRFMf7RZXtnRNt/FTqejXgI9kDsJScCobuz/paHsuiHiVMSy3/OazY+og
+5EGV6I2BDD+LtU8TgAhrDkNOICyIUsdcA9j/KBBtbZVFk63bZZDeAjFa6EY6CNW1
+wM1BSBPVmthARoORFO+0liey7Gf+UUwpPZWBLnpOnCSYuLW0EeE35hipYjG4BgTR
+lFarQ9ANwiRMus/z0O+aRF2UoOxaZE9nn8q9gNmE/zi8IzB8BgkqhkiG9w0BBwEw
+HQYJYIZIAWUDBAECBBBWHb/n+iCAACPByNQyvDyagFAOI/Or8NCR566M2NqxqRXe
+AcQROk8Bjicot0Lbp54z8XtCTjaRrbid87LIk+rItAWYUaQhbq93KU6TD49QNZuc
+WuvqYsDtiMqG5GHEVIM2JA==
+
diff --git a/crypto/default/cms/testdata/encrypted-aes-192-cbc.msg b/crypto/default/cms/testdata/encrypted-aes-192-cbc.msg
new file mode 100644
index 0000000..51b3f27
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-aes-192-cbc.msg
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICHAYJKoZIhvcNAQcDoIICDTCCAgkCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw
+DQYJKoZIhvcNAQEBBQAEggEAZpk721XN00FQKVPo46sm/yXTKjMs23r+VcPc2qZk
+087XHg3YbsjbwCj62kAJdFbmBtPXHQBg9Fikbrds9Y1Pub8EmZg4ZbXSpkabzBLj
+73DyGo4HuAz5Lw/+uAxtthGl8UePHsY7m2d/FyTgQw7dRk0AoCSpge3Ad9lD5nms
+xOkgJPpnYJ+of9hPfkIfJTbPUaJ+XzUAQ+0KLzeCsxLu1bjEAeHU/ZWL82vc11L3
+xBmlg/5+ScCMkoKnTZBQfpH5JSRGg2gwbcjVF0CCbyNYwXGTuUuw1ztGXEDP/wBK
+QFhxVgIAW7AdK8A4Rw8tEmo8rNJH3LAMusmbfmaMJLioxDB8BgkqhkiG9w0BBwEw
+HQYJYIZIAWUDBAEWBBAQd5RkUFRU3UCbu2amDoXcgFBETNV71rk0JW+ypBUeeQLT
+JlpRh5NMYvkkG0uxQ8Q6RJFNByhUC63ZXhlHUWsnP0H5mmcoEInToJz6mCakfqsN
+Fa2rGuF+ng6YWeADcfsXNw==
+
diff --git a/crypto/default/cms/testdata/encrypted-aes-256-cbc.msg b/crypto/default/cms/testdata/encrypted-aes-256-cbc.msg
new file mode 100644
index 0000000..d64a766
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-aes-256-cbc.msg
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICHAYJKoZIhvcNAQcDoIICDTCCAgkCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw
+DQYJKoZIhvcNAQEBBQAEggEAd+Rs73vpsjpBufLIbJFt29t6ZhWYtxfl7e/X1nLk
+obRu5BISSAd1ljMDMTSvrGcFDYz95HtOQFdKUdrY899bPJ4vUzjbdiylABULvg+1
+wWUzPCcAqgQOZipqKIxiBBAfSkW+2uaxBPFPqYcHBUet+6/8V3cEPe1ecLI40p3l
+3r9cTJ3MeSeDQcefAZLnqKyPVQlwvf9OuBbM/0qtymUaZymhkh4pDPwil+lt+dak
+ZByzwwZypBsIyzV7ih7cBFROk6tng4X1jSIQydlDlzulLFp6ZA/RjqX2vYtiBxiW
+E59uVyJoy6soqk4fpINh4R9oNXyKiL56jJ2zRZqLxinikTB8BgkqhkiG9w0BBwEw
+HQYJYIZIAWUDBAEqBBCuGbDkYCP62O68MK7GgZ61gFD9Ub1c6iuvQZPWDPP5Piua
+YVxJnMVpdASITGxKRfN9Pqjm3XYSwrnP4Qwb7PEusD7gLO2/dg3u2S9NEjyWB7J9
+XI0BN+4uRTyjGsVBI1fXZA==
+
diff --git a/crypto/default/cms/testdata/encrypted-apple-ecc.msg b/crypto/default/cms/testdata/encrypted-apple-ecc.msg
new file mode 100644
index 0000000..d1a594d
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-apple-ecc.msg
@@ -0,0 +1,70 @@
+From: Test <user1@foo.bar>
+Content-Type: application/pkcs7-mime;
+	name=smime.p7m;
+	smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+X-Smtp-Server: 127.0.0.1
+Subject: Test with ECC
+Message-Id: <CEEC8370-88EC-4031-8158-CFB80F76E3CB@foo.bar>
+X-Universally-Unique-Identifier: 1AC166F1-C772-49E3-8918-BDA7687D6593
+Date: Thu, 5 Mar 2015 11:11:33 -0800
+To: user2@foo.bar
+Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\))
+Content-Disposition: attachment;
+	filename=smime.p7m
+
+MIAGCSqGSIb3DQEHA6CAMIACAQIxggISoYIBBTCCAQECAQOgVaFTMFEwCwYHKoZIzj0CAQUAA0IA
+BNPT1E5XAZTOFa/IUH3KWWlxa79kK6FUZ2CuQtoTNbk4Nuw0L5wjO1ERzBOkTqJjJaEdxjmxwhD3
+rxOin30eViShCgQIbW2UCcKHoTYwIQYJK4EFEIZIPwACMBQGCCqGSIb3DQMHBAibxTVubvK5lDB2
+MHQwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx
+FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7BAih/SSY
+X1obMaGCAQUwggEBAgEDoFWhUzBRMAsGByqGSM49AgEFAANCAATZA9kLSz3GhA73mWtqnXuDIofx
+tDYFK1ToUet/jI35TPI3CcNlL00jdCdSjBwYWr3u1Clv0modfCvKMs9tCXftoQoECFtjHTZW5H6X
+MCEGCSuBBRCGSD8AAjAUBggqhkiG9w0DBwQIEoCEirtZno4wdjB0MGgwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1TbWltZSBUZXN0
+IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBAgISPAQI1YJqkSxroa0wgAYJKoZIhvcNAQcBMBoG
+CCqGSIb3DQMCMA4CAgCgBAgxHl5JQlXGYaCABBhwHvugL81nVUsy9mP3hkQ6nHLLSWIzscMECC8p
+HaJgOJTYBDjZ5lCIMfnmnoYM8btELNdp3eUY4cZGr/cjChOz1tBWFKvCLzBVQP0KvhnjkVwpwA3g
+gro9nB2DiwQICrW2pBhtCl0EKNqs5xz66JVO2XooDB17SffKyuHpSgC9zSfTUzLaTI40Ky7fGpFg
+gMcECDD0Vnr2IpdJBAjRjgcAwenesQQw6WSl2IQ83aG2fvteYO8fKLlOeQC5hw9shacJlwh+JtVb
+8igwxXX5YlhzkcMKtT86BAhDPTuQVHlSjQQYAblXqRSzDEi3ryMAh0gBkjyXZEU/NP5DBAgkomut
+TN1N4wQYZDycW2/AdpHuFrJs9ozFKsmF+iWvUwICBBDPUr8Cz9FAVDpAubN4FNCIBAiNsB7bnfL2
+IgQQ0c4C0FT6qArhM6Mm9kWkgwQ4ceIZ+kQOz34t4YsPXJoiR2D91ExV/ksDMlAAxbJiYw02CTJ9
+M/HL7ePWhK43Wi7VUxTPGE8DfUsEIPiyqiHMQXy0YVL49Z9k2ywJc55T999Z2MNJWNuzeOqqBBAA
+Vw/BKRM+PRjb9/gGSimwBAjm7ueUe8VRpAQoEn6VCEkn1SwBJFmQxaOv8oZEIXgsKqpFNA2PEuAL
+41/onENdfeBdrwQQmHxp1vFsFhkpQbHwoIirrAQgjcGUGSt3ZKrUTxqPFXU9R82GCSQs6xb4x6o5
+Mog+suAECOxfKL8DvzX0BEgnWY0daRqyB0xU0rzqeeseWgfSSE4RB28wFccxqkTTpslGGiqKiBgH
+T90HwkSO60I9UlGrsEPlAq2oCDujQ6ms47Y7UPrmul0ECLNR3BNo23uZBEhQJaLNzXTYAcWteqlU
+Uzjv8QvGs21I6reNyZlXWgtHc7zoPh4M8A+0A/nd+R6H2fHRUOcgvMU73XyRVgRtdNJhIMJ/ys3q
+2MoEUNdXNR7YfSVIyvnp2VAd8D6AAPcrd8Wq9+4hgpTDB60cIXjgcBLZV6/EV+HaV+qqirRouX8O
+ySgT5ZeCnqnTU1OxlqcRC4/lPQlaYe+ySHE2BFCJWfzk1vdrvW3Xbk8MjvK+BS9rBZ9Q8vilk75X
+KyDyYKmopdpIGMOjJXkoO3+8iyB24d6gUun5yrjpbX34XtluNijKglQtLKfmIqPiiiOHhQRI8nyL
+V74+qYfh1XXOFAbRvMmqtovELb1LVguKb2/u1Ss+3JVip03DFk9KfbEPyxTthstrtHasaKv6+icC
+TetM8DNOI6rJhZ6cBAiKUbwJYMW+yQRI2Za1BGwPOYrl9MI+dx3rKl996+tbx0aPxnRTWMOhMPbM
+28pXftHZCl/fRsn7E3MrrE0EVC+Fk2Ij2c9T3NkhxaGmansNtnDvBFBm0BkY96V6UppNyFQBntEf
+cIIgsPL9VTOICmjrZCjFUmSRW6JpTYoVmy8StmSWvP1K95HOLeI+XWv+/bFroyyLhUQHiiOSQCmV
+oasnFFjlUARQuZ9v/YB+7laHoCle67Vt7idJ6qWxi3jVXC6mMc89e2TSqdi6CvYuPjGA3ezgHQUX
+b4l74lf5nYWGjCh+z7/eqSsL0sIOUsGa0itLLH33UPoESIt5TnrcDZX6VsgV7umd8pBGWoAAmcmx
+SbQ9QPNZ5HkfwBTd/e6mdEkIzhSHlIU5ZvBRIPqaIJ+SZ8q1P6KXS5qPedaU8bG8VQQIVt0K4+aw
+6roESNR1vm1jv1sbgAvTplDODldq7jPGZUmR2c8RsYu2WsEd8U7DvindLDmUbPjGCEi9fpMF/2GQ
+RV14sQ99JpIc3RnAlmOWwjJGOARQt/7H5Z7EAX+aCXxsTgaLbaOTB55n6q0S7bRHn4wGOpOSKMs/
+RHUaO5bC+WzsrdVVP4yyU3u4f77o50BAB341ZQcQMJqpzGf6Sq9jQn8+fqMEUBnIwozNyVb9ssG7
+uvSFX4YoDqHvxxzcO3iihxempHwnvEwbDJuGXucjGRRf96+G1RzWMUtgoPKj2JAaGKRxLortgIgU
+pe3sBF3OgBqMyexxBEh8T6B+fWszvUjvTxefA8XzLe47TljLW/SDxyu8Gkcr8EygV0tIuHTMPAtd
+EaYw/6b6d3i3Rxpp+sGZFo797VdD/HRdn6D2DPMECFSddINkaFokBEjUFuXEo98EQPt1KF7ZsLpS
+LkcSLrxPNKorgQXrb4HiCA/hsTK3oE2fC81kUDOF8+1jOPvIdGBNF3pIvKi6AqmQ5HQG40wOr98E
+UFOdQuuUhnXd2nJ/tYldJz85dNIYtuNgWEYG1zLzoIRBj7rDHpmDfg0I5Su3NgdrMRxN9QoETAZ8
+e9em9LsRzstU+Annq1+HmoBUd/lN3hJ2BFBFap9q+v+5Id+i3nNof5bZgr/CbEPgaj5MP+4C5+jc
+mTXutLiShl14O/X1/hzIAElX00wcpU4ppzGzqSwB4XBoystNPbNK/wYQojaC1M4KOQRIIQcW/LSM
+T/T5ztfC4KtTx4NVsZFRRjLh9QIMu5hwGY+g6uQWHd3+x/AZrCEHt4OJEShFmeAlzRtY4HhIFMFs
+gda/Y1lUEIJ/BAgBxGFCFJl/SgRIGsjPoRhsaY3HYa5WtEevLF+9vrDnf7VOWskPYqK2/DN45O4f
+ZyRGzm93cnS2LWzaTcok2sxwY+vISuxIPM4xcEkWhD5MqlxhBFAM8tlQhapRp+u1EKlSZ0rc0Xqd
+EV0v0IRgByT8u3b5/uzGDa+AfPYw64ecjKIrs/1RBwk2KqauEb/ExZKWCwFsVRK6xgW36W2rUM5B
+GkOVCQRQVNaQrafAQYUS1eYbgaMvLbtyV/qdbYnzyovrwXFh05ak27dWaEiRIEVzo0nCFPTpE7NS
+vpMWqhdSI4G5nezrED1x/+4ZXdvHqyYCCoLDvyEESHqU4KAztlgPq57DjauzhSLVnmqXd5XbljwY
+uGNp3UriAwI9ci0EyM9UqHFNUEEaqe3gavKqgcwTK47RMo6knwmapCUJfjhJiQQIaI83xPpYISAE
+SDt+CJUMxTIR93d56BxaXh1dmno2DyShELsvyDBl4Q0m7YALIE6MsOHJAr7JiyaZdza4Vz5Kz8gy
+8kG0r1TyPqr6WO4zrnJofwRQW5NvnGAmvSV/W94QDzH7ncGx+7JOXQBeJoy6w60xA0ab0z66YwUG
+xVABdZnS9H5o1vvxi36C7btlnpw3QHheVbOBe3SZBdbtPBPZkr05+TsEMDof2z+QIp2uV9hzuEC/
+Ul8//W1MeL8TMds9vW+CRgeYa1V0Vs8e+db7slztgRHc/QQI6RfZ76MHK6EEMI4vtJkUAN5entPG
+4mm85gbiYapsApV5OLXKJxM85faZYGJtm7fb72dbJcr5x2WZCQQIDrAEpZ4bKCoAAAAAAAAAAAAA
diff --git a/crypto/default/cms/testdata/encrypted-des-ede3-cbc.msg b/crypto/default/cms/testdata/encrypted-des-ede3-cbc.msg
new file mode 100644
index 0000000..b0e3cd6
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-des-ede3-cbc.msg
@@ -0,0 +1,17 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICCwYJKoZIhvcNAQcDoIIB/DCCAfgCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw
+DQYJKoZIhvcNAQEBBQAEggEAoyE6/1kCKQRwOYJl4jaCHwGXW0DTpbUJx5sWsS7W
+h1j7BpsdGaAlvl0Tq7gvdyEfGm+uPpJ42nqelce/SchU1vqy1TqntgcIk9IxqQ46
+2KC67KbfjehtpsRIEpv35HdIh6wcLRG15abHpR62rVKbVVGca62AzwnYCaxuqlfi
+FwMCdlbpNnpZejkKbi/bCVsM7zZiDRm6Kk0ENn30N+6sFRrZNvijbkWQrh6cxXm7
+V8PKke487LLgETBdGODUI3qn15fMAhAGJ/0uDkn11nXKpzVS/3QOHCo6/JN4DVtz
+kLSRi11eXjE0wygAPTfD5blmuapEXJxQi2UCM8xtFUqIGzBrBgkqhkiG9w0BBwEw
+FAYIKoZIhvcNAwcECC1Jhj/M4ZzugEgUlfNnY/EuNPWGnsgJE4Iqtb6I5+T0RBEl
+GGC6vLQOM2CVCrKORrSuSPvftJbgbxpEAndcvI1Fv7HhCroO6XD5IYVsdDSrSh8=
+
diff --git a/crypto/default/cms/testdata/encrypted-openssl-ecdh.msg b/crypto/default/cms/testdata/encrypted-openssl-ecdh.msg
new file mode 100644
index 0000000..28da78c
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-openssl-ecdh.msg
@@ -0,0 +1,15 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQIxggESoYIBDgIBA6BRoU8wCQYHKoZI
+zj0CAQNCAATfbqiA3E21lSCC0PGhnGEBnx00tt/eWG1FFefUNiKUm0zA1jxnJWFb
+Xs0KQhqPFEtTu9U7wkmr7wwq8a3BHF5BMBwGCSuBBRCGSD8AAjAPBgsqhkiG9w0B
+CRADBgUAMIGXMIGUMGgwYjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYD
+VQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYD
+VQQDDA1TbWltZSBUZXN0IENBAgISOwQoqa0FUqYfVJUWvnKQBmcDaUSRgfyEX9Fk
+dEXUDJZwYvK8LApY7bEsZTBrBgkqhkiG9w0BBwEwFAYIKoZIhvcNAwcECKdMO7Ol
+1HxugEhoVMQK6ROFxD1ZPLhXY0HzIuyzaR5aIZ7pEaoo1FEqhgq5VLiqko+5IREa
+1Go2ta1JmnGbeG6kn2qvSZBqZ0EZALFXsWy4RuQ=
+
diff --git a/crypto/default/cms/testdata/encrypted-openssl-oaep.msg b/crypto/default/cms/testdata/encrypted-openssl-oaep.msg
new file mode 100644
index 0000000..28503c4
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-openssl-oaep.msg
@@ -0,0 +1,17 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICCwYJKoZIhvcNAQcDoIIB/DCCAfgCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjQw
+DQYJKoZIhvcNAQEHMAAEggEAALuoPd9oa/XVWPUCmm1e4t9a0WvP4raKUgqroqfc
+1rvZgMqB8hRO5BnqJTwQxvlN0MRII91P4K28dCXuCNXNgGZQOLzGKfQ29VjQwuAT
+ktYWlwT6gs5hQeSGdg63pc4BxMx2TKpoqA70wnX6q55jwF9ncTtyOmQS3cgGOC6B
+0XI5rj2GSAPGeDnMmPYm0N5GWzG6mbpyGmm0zlHKh4JAepUVvNTj+Cvdc+mGefqu
+90kq1nOUSESdmkRj04Whb8UgqSpTOy9XsfHjMI50rxNT3sf4K981SCZ07QRLKEyM
+0W8YlxyohG7wJllk7PpxCEChc5rDv9iS/2Yd3uhis8iQbzBrBgkqhkiG9w0BBwEw
+FAYIKoZIhvcNAwcECN+m5nmRI+FIgEjBLNmFLyha4/i73t+cUeByWbH/uMExvaNR
+mDQCqeBt5+ap6CGiN7eAZiwzjS/Y6nKqm04fpyCyexgUbvAd5jVfrtOVA1PH8wo=
+
diff --git a/crypto/default/cms/testdata/encrypted-openssl-rc2-128.msg b/crypto/default/cms/testdata/encrypted-openssl-rc2-128.msg
new file mode 100644
index 0000000..d44f878
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-openssl-rc2-128.msg
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICEAYJKoZIhvcNAQcDoIICATCCAf0CAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw
+DQYJKoZIhvcNAQEBBQAEggEAjutrCfZ61QiKlyK9H7AfXa7Lr60tyrU/y+Yq7SXX
+z1gO5LB5bFASBGKSgkjQJdbRKGV5XtwzFzOY4ltfWrWKxbLy9BQdA366+R31+RM3
+qy/AWMl9WlylyPgKQJj1j4B1EjheJQgvsOIBBGrNGnlxsIblIl38ht7StICTIEpF
+2pw5owIRIpldW3hdkFiMO2uytewkB3yJMBHevzn7mGPVoUaqdDxgjvDUQG4DpK6R
+yEcCSckedQSF9XdaSORtdbTKTvL5Q/A47AW93N2SneNy/VsNN5VHLsm77fsZVqfW
+Lm0kFoBAp7Gf17g6RiDomnT5d9n+u8NTLYmOpoLovC9/EjBwBgkqhkiG9w0BBwEw
+GQYIKoZIhvcNAwIwDQIBOgQI/ewlufGbYJ2ASCL+RnVVq2oFtjObvJTijUtHU3Db
+1pwKIW7wR4/hPEUX727kyowut8UV0ppHcd2NKKT5aXYSthQJ8EuGvCF8iDXLI35F
+MjJN9g==
+
diff --git a/crypto/default/cms/testdata/encrypted-openssl.msg b/crypto/default/cms/testdata/encrypted-openssl.msg
new file mode 100644
index 0000000..78fdb51
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-openssl.msg
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/x-pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICLQYJKoZIhvcNAQcDoIICHjCCAhoCAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjYw
+DQYJKoZIhvcNAQEBBQAEggEAqHO+miMwO5F2n43PZB647pUWRxBAHXbbl/RE/nPf
+EZcRCxt9HVxr7EE3k9TIY3HpqWvZpzE+Aut79vlPzJJJI3vAN99j7UaB190q4n2j
+Hl2kxCc3UW7N5OfGFpFQi0+VRDSPel32Rr9hlGahRzDTfUUr+YBYtSLs0CE48nRv
+S0dFFXdT7Y05mMiGgkJNGQFtEaqgwguLPChl3rK/D3tDa7gt8QkIuo99xxnsEbe6
+6JKm6G1ZFolQF7MC4s3PlQThJxrAsjvPJwxNPXXEHtJPMIFJ8Rig6KwpSRDOZ/9D
+/DeZWeJfXq0ftTVLsk7lKH0abXMnQk+hHwUJ8PN06/ns0zCBjAYJKoZIhvcNAQcB
+MB0GCWCGSAFlAwQBKgQQsLamQGwaagBsVnQA1mS/6YBgCtD7SJTExzxtJV4cWfJL
+MxAAsM4FjMHSNn1zp6hw7qXP1GdArT/PZfTprLGYKTcjewZ2krmqdzHTnyiw5a1Y
+aCo2FLo8r3lOGnECVkLLAc9AG+FnYdjPvctByZitXTcE
+
diff --git a/crypto/default/cms/testdata/encrypted-outlook-mac-2011.msg b/crypto/default/cms/testdata/encrypted-outlook-mac-2011.msg
new file mode 100644
index 0000000..5f0dac2
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-outlook-mac-2011.msg
@@ -0,0 +1,117 @@
+User-Agent: Microsoft-MacOutlook/14.3.8.130913
+Date: Wed, 18 Feb 2015 23:25:31 -0800
+Subject: Encrypted
+From: Laetitia <user1@foo.bar>
+To: <user2@foo.bar>
+Message-ID: <D10ACFEB.2%user1@foo.bar>
+Thread-Topic: Encrypted
+Mime-version: 1.0
+Content-Type: application/pkcs7-mime;
+	smime-type=enveloped-data;
+	name=smime.p7m
+Content-disposition: attachment;
+	filename="smime.p7m"
+Content-transfer-encoding: base64
+
+MIIVNAYJKoZIhvcNAQcDoIIVJTCCFSECAQAxggMIMIIBgAIBADBoMGIxCzAJBgNVBAYTAlVT
+MQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUg
+VGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjcwDQYJKoZIhvcNAQEBBQAEggEA
+koqSTKP0d3rCInHm7Imnc9kJum15rElh7jaSyHwmVP3LwbPdNVp6wOLSMbiz/G3lqJqdGpQI
+M5v3EeLoqRBU5mgdhmjCjx4Pc+xo22pjvqtocwWPp9GAUQ+hCb6Zohsz6gFBhd87E6Fa36Gd
+eHKAUDfRccwE9mMjOw01D+zYkDR9Wfe6RF0T7PURVN0iUvDnse0K5mPKvDxNd7xvCBMCrfzq
+/Cnado2eHim7joHpCeyT5l+ZyKEiS5d2k8DmLckhVavb04vRmLGpkWfjlTSBfLXaxBtPiTv5
+UsC+B/EiIQOZtuPr3T/lbQ1NuyfQSOwR/vNYDakUcOW9Am1VaurArjCCAYACAQAwaDBiMQsw
+CQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNV
+BAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCSqGSIb3
+DQEBAQUABIIBAI3uwaPGzjPer/dWC29KGESjSq2jBFmvU1f/uFzOB8KEoYmvGP+mk1U8himz
+mwGeJ/7gVzxz9s8vEAx68oQC2NgdgwKNRRQdQEvgS2LoPS/E8Yk/f1EGjTMOF1WmZhjWBN9j
+5YBpxerxHee1wlkEgZc87LsnJXB/XAPaNggyVz679tGA8g06xHVkjXtnRW6uayZLcudcOQEW
+v4rQkzVSm58Ry2G7UqMsDWtl//4hjcQp4Di1qw8dECtid9onsbwucIcHKZYF184+GUHjZMNV
+aLbB7/fH1Ii0lls9U/oDjYKQQyfEZp38zd7XM6yIfgCFdgC+h1MCu85ytnAg8ZJ28G4wghIO
+BgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBDYbcU3WhO6EsSNOg2mEAMWgIIR4HXUbFAIWKwk
+dsCzaRbUnzs1NnTR3WfwzIysvueNkBtEDmJQz0ppMIl5tS1+4chL63U3ChQn/64otNfs1HZR
+qeLq+0/TAHx++mXwSqxdnZkE+GNPNZFh5JxUdky0Y/M6jzj0lMyR0V1ahlhnoiuuZM26A6MH
+Vg1rkuSqdXyKySTVIX4Ydu4lzfhyjCySbr5t0WSOqef11tEf7fMs5LEBo5T5NIVGiJRJ2NfX
+fOJPFgMtHBg1H8hCtgbZzaiA42CLRT04yy9aSa5pq/rdGaTo/KG6c/EiZRJxUGXaS57GUQzb
+yo+CLgi6P5jkxCQ3lt5vug93S2/N5IkEJMk9/kjvWGCaZnFv8hYAuH0MWmmJPSMEBzoewb/7
+IF+jCiwFAVj63m6YA74wgYTVPJ6iyKQI2T6FWmx2js935ORXypQUS4sWSRRmmtL+GkNs+FrP
+ybWdjtafxYnBtqvlGCm5cpXlLmxav9c1lkV5UkQ2nO1T8RMEQUNJ1nDpB12nuMgdP6hjM70s
+xyTlbIEDAp21QgQ2UpUpgI9rtjHNV5vty2INcN9hWjbuXs0LbTC4i0UifONoOp08ZLlH7JWn
+mDPn0RwqaIdTmy3a+HZq3nRLYkL8hemsiz0tEdwW8Rzee6gZyx/ziuO95tplSPDjN1snKhIv
+VM3oKJCXMDb/+BMftH8Niip1563oqNZWeLMm7NQEQtQFZQGlFfzpePqNI/gEm41cph+mx6VI
+Jw45bnDKXhKoGDiLA0TC/lVJYmN5bWwzKW6UdMgJe9Z64pGJr1QQq0D2PIWCBoOaUiF/lVwH
+zZOe1o7DhanWjNYl7QF9zqQ1hmXVnTVV/IDWHif+oilc5YYvHH/S/nisbfykqkGlqrXH7gl7
+wqQ45Lo/6SVvChyHv3Lmqz6gr9e0hwmhvT3NJQmtyuHcVOL4GeC+RyxSzfYCvsvwpMmlPC94
+g2qJ5PNVzBGvwzJlMpvrxmPfUpOTaHOVGM+9uxitbMl9hlQoTGJqw/KKx8UIRGa376WC9Zmv
+4AHUEfvGIXtMSmuC88l4D2+V5bqyHk2qkMtcIS557jk5fu7xP2y9MqmEFR58VF/TtQFxwrSy
+l9iVvGNrFwPqWw7wGPmKd4NzVM1OPEJbM1l+V0Fl22j88fbay1AOtkMlyuN74rDPZhh1+OFK
+/RSOKo2kWiaM79dvtHxVVl77KFrHV92looCBd0hPWsR70T4RrCmNeX6lFPhTUftxdFMy3qGo
+pDUpsbzXKxMvo4qLRs29LtWbYCKM5sP4BOpYMXhDNt5iOySRwHIJhVKPdW3jsfmCsSIfGUPV
+6xbGYBtjtllBOx0k74pr5lhClOapodVHkGBkGS2i0zmI89YLuQQl2thewpjTCzZVbxVBzEvD
+Xx9n3h/j16C9bW3Q0wG8Ivc5i7B5aUlrCg+2P4TDNSEtjhksruEWzAPOJlww5Whiwx2LkYH8
+T7YiTcdR6I71tMDJNCHaib26U3Nf4tJiZAnXnBh8vGch6JGoNE431w0FYviHtDu2uQOylnFs
+1GK6367rEr+UMFFbMyCk0K52z0GIBNtrfczToGPZUnwbFL8EUgXcyywtWDx4qe0j487rztD+
+feTPiUW2Xag52WXuki5A4zwJo68NExovqI/dwgiRJ8oPvx6snUqkJR2FBWJxa6hAz/0OVnmM
+0jOgWfrQtUIASwHmoEPFt1GShdNObDU3P3aYtafqTkQmOD/ScHMXN3+jZFv+34dSGr905C9D
+aUYqmbH1hB2S123D6GbexXuOgQDET1i07qr1JttR8Id8a1/RfkYKdJq11qJ6jSBFOuSfLuSe
+heBE2yWRWWGI3MjrTU0UbhS5nSYu+FObO9ijeDe/S+1RuGthPemc2M630pFeyuKPo0GHOO+p
+CRl73X7J0YWh+Y3phfne2wF8MoYa2XIGGqctnxM0rKyG/WaaPkG2/VTXcexiREpWSOlwtYVb
+ZCCGjf722ZtB6w8f5EAqhNrWIv6kxDylx50SBpmfNQcDFMbY40hUQPjuozlc+qsQEv+MK9iR
+wJ0WERTrgWyKPXQ9LazFBJL5m67GMsx49qigqIYF5Br0jjDJ/pGXqNHkdBQyZg3BZKRjQJ2P
+8teTJMs7l+p+v3qM340i5bjJ6MQJoZo9MPZvBu2d0nOZGhZNKOQ6SgrotRRwZL2NfucPlpS4
+YtswAjVobp6KsGJWR85MYEW0BCJDJFqlDVn8W1O68zqnCGGWd7Bk4yRWcS8Z4A/0GEjjJOP2
+s9LG6iIaF9m1euHseBObHCTB2Z4yBuiPAI4n7erZmDmc8hCL058MpfjsiEOs30V6H9XVuON0
+FcK4hYZ3TeNRANZA4jzariBzKcNiVh7VVTHGGSIE4hjQIoOZlxXhomdG+ugE467Op27+geVG
+BR20gt1DK9N+NdW2wBY35/dbvN/8cJeyCJo5DTrP+X6t+/86LrNdkk/D5CoeI6c0Nqdr7hB6
+QPsiJlCsbC8RgWnPYmpMLkWMDySRrR/sBoVTmlBb5TrmriVO6umD/W4eOweqB+AZhzhgLenH
+a2aGM0LScYS9Pc2Y5p2sznYygEApWrEWXfB08TWDcq2DfU495G/YCPLLULdN2liA7NNy2oGD
+cwINKbI63S16f1WhoBkMdVCNUDWo61J+mg45cEy/32+6YZHjUPM2E5yBqzMqr64F6gI9IoUG
+72YqY59P+GhPeSblDqRdBj6LoQPNm53PksZKJLqBxJwrlg4hQevc+6d+L3Gi8kNN+EVxstMS
+HYeBavoFmYENzGivWAEQDNLE4VdMlyKCkaUo0BxokJJhGtD3P+ecDxhlC6DzbtaU0/5xQ6HM
+lGsZaRL0HvDtUyzQ0G9TzGT9E+8oMjOBn+KaAGZT7446u9FhtU5105jXD3u8vybpaEDtZygg
+f816izrA4r/k/Mxcx9OsEzaJYKVE48pub3c6mXeRT/Q7WGmAzXRh4bm4lVkjDXd1z855QVfl
+wE+ShwMr/vn1ml0bd3KaMcigYbNDX3ym5bMjU1+UAEyRJ5/87b/j+4wu4RPtVYK6Nqa/raa8
+iNY1z7POdA5JhTqr0CbKbetLEkADDksarKO2/7sFKZDIstwT2+TdDPpYFV0m4Xfthfq8+mVM
+6qXaxPi26ku0WVt9xYgaliiQ3T+TPb71XTfR0euxzPQdEA8ibzoApSajzvw06mH1hwtW/RIs
+Upt/40IKn6/Sx6rZ7AJQ5WfdS+KgDQwV8x2HtLmjMfnO6aNGvh6T8JAC7OhmUfzV4y8TrQaA
+YEemw6rscQpH+Np5xgXYayJzieaegbjX45a7rdeElnDz5aekhjgbSR636ZeNbv2EfbPB+emK
+sLlfD4/+r9kRk8SUKeesa62ulKCjoJM+9IqQpjD4AEHQp2fJLIEOzWJOZzChN+MkkPlFkbKd
+bX9MUfKGX1qPGKFQzr/vSMxr7K2IpG5/tqXflbcNkW/APC7qo8L7riXM6BySVVVpKYvBhBY8
+8u9CHK3bs0+DZ/479XusYhXWXY8y4MixXuUCvhLBGYqH5nKGGsOvFVq/EVdsg76OryGW82T0
+vaIlTu3acnKakQ7PGZYySwj4dzHG5u+ESsnvIL5QyPsxzXJ/yhjGxMorcuAbBpkVP8gfsq2h
+dSPmVT4WFYS+BXTCDE1jAl1nKD7a7NXOx5V4n3w8bOS1uGxDBvOlbqEcJysFftsP2ypB+y8z
+Rj/bcEiBH9rraKsQjdHDxSfQJKMEjO6j1bB8nwtFfyAFI9bxrqP+iBZkRjwTgsd80FNXRAv/
+BtdNyqbN/5lnp20aDLioVbrdSIMG9aaHFm6VPKQoihfI3rtv6BZb5v34xpLV/ljktG3jvGMY
+a8VHHgcK/CDLLuZwF3ZFKsmRdiVeHy5Gw2ADQGmMBONiYDa4GsCsbfCidiUt+8Zmp6eosO1s
+ghXFU0pztM8oVEh6zNwpk9CB0GX8vprQHCpJvbtI1EcmIdc7gZ+Qq0OaqcR6delcyFl+t+1U
+9C/2VofCGB1nXBtGdfO1wmNCC5yuYtiS2RqX/ibLIc398aRrnWXXXLGs3TC9M7obnSOih2xE
+I0/ytk5rz6CIut+2yHknSYtc0+wikDLfno+m9RAwfBRraAYq8Zquz6GnEmFdesIW/zE+W3uo
+OzeaZpIjToonMVjSsWZ2C7nEwdQtvCIDKUOR09UDCUFWIqXyXWNsrM7fBEnzpddTtaDrCESU
+4nlmPPxrl1/JpNlp2TLYfEu+wOoceZKavd9JHM4yE0687cxM4Z6dasGeW6pV3uWG9BREvq1N
+F2n8ANN9OIpLWAQeOONhVqonFKGGuL7elFom7PdSFthMcUr6S5NIBNYAETt7fbRDK4nQbwu7
+VdjbFEodYTsBbbALaYxA9Ek7CRKA7vRXS+LRS51e0w8821eYLOUAWDqAZ8S+4obC/Wter7Gz
+76xtj/O/1z5idq1mtBoOEeN0g2yEuhlmCH6eS3K83nzDiyBTfiEguxeOOvsCnhy+ZpJI2wck
+Et3e9oqRGsdXPpFvlNEUDlEIHRTJ/MYiKjbhc4KRZKUgIRBaM/MebcIDCUrsBilX8Iz9Iw6k
+13NCPdrMTYV+j0N62XiakGhfLq74zq76cdpSmBRZyC5Xb1baa0OO6KarZ3hCO/4CrDLOfVQj
+2aEvffECSDSY2PEWPTvxrtEFrVc4s92CHy3E9UfwbkWAlCO65BC80ZfOGv9JmHDoLnNB90oC
+TlsDYkIpG7a1WTaPxwPfcr2ODTZsHB0C3r3Syuvry/c0v7MRCRscGNKAGfKbhiprfod79n4q
+BoOP/I5roQM4X5Ep+3Xb3qM9TTFq02uxD7D3SqM6w/1AAHByeyx5ET0ETkp5wBrWOkWMSo1P
+uMmtKJGC/pi+uX1YFvrO6UmT+hli4aK1DTngit3PQB5nNxQkff8a7bB3mtEYnzgR5oChy79J
+wclJCwKHS1Oe2gzrpITLQ+uOMyOd4Rn3bpITtBwXhI9PtLJahZ/NgCOMJVqToIzwChWwFBW7
+TFPot2PbDR8AB+A9GUUzj0fjs98zLbw6MP7FcBd0GwDi1u4w9ENUcHdFXDO4aZ2xud2aHA3Z
+yKfY5r+9IcKYlya2H+u7u3GnGEj0JO9fd8ETYiNzThKmSVTKQtWULLODt/S//HgKz/gXts7P
+dp885DzYMj3DlYmHxYGKZ1DX+/jr7HgOFzaWXLHKyCtMIlHStWy4+4VMmqg0W2KPWc/i5ZWa
+IgnBWvwoy9AKlzuc+hmallv5XJ+mIEZ6/Q4IPz9KZ8fHzAP9s3WcNxRjx0//OYeN2faZtcRV
+aTkgdjvzHvFgH69KXwE3YoP8evAzC4Ay+m5LYmz10+PRw3E4MzxoWKCdxigo+0pUQJfjTY3V
+5QGfdC1/yn3HvzvLFMXyM4+ejZ7ZABAFxGza4qLCJCqVoWH1dm7DJz4OdVFeYbT6SEB5b/pN
+DO7J0eFlQZ9ltwa8Qq3/AWEzziJNOW8efzQaLzUTF7Nz3CNRrcWTZlrLyOvlmBTbjg9aWsgz
+HEyF3JFhHecZdtDM9bTLHnu8nXsC1dek0isT1Z/bRTkCr2QML28Gj5/c5933iDzObZeRQ5jK
+5DSz8Q8HXIH/cu1BIfrGIBMWNtfBl8iUb/sgDrBNcsMHX1i+vIWgcZh/n3rv3a6Rm2WlhVQx
+6iWLw5xzoGs6RdZMayw4vERU+RVXrxQo38ZUUxYUzbX41rfy054IqP4V/2Ms1t3DvWCsTjVM
+q2/FJ+rM9pRNO1t+DpcZ/9CRkT+BslJYE6KJyNEeUGqMiwgfRpIOetT1KTWYmnWHbgg6R8BK
+XYbpJBZAyDfalbk+KGfp0VmxXoZ0jo+gn3oPoN7CIHGL8e2pez9zwTnyMQwaXSSwFr/MxjQl
+36a4Ju3WeKyAvYvqVXGPf386WwtN8UWJwO2cjh9/P0dgC+dsXARJihOwTbLsrGtWyrWUytvU
+2u3CX41kLk9Qypz3iH7salb7gwPvjSr/h8X2DJYrIDbpVqBoqI06iLDp8FOKwT8l3TPhZySy
+fRoaE2tkeaNkhmajk5OVQO9lbccrr+zZAULxjxrtVhm+VqrJWn5KUwgWP2tOf3HbBwqCUdlv
+ch51eUnCq7249IdU2VqIs5ZtW1JemoH3N+Q5U3t0zSE=
+
diff --git a/crypto/default/cms/testdata/encrypted-rc2-128-cbc.msg b/crypto/default/cms/testdata/encrypted-rc2-128-cbc.msg
new file mode 100644
index 0000000..9385c06
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-rc2-128-cbc.msg
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICEAYJKoZIhvcNAQcDoIICATCCAf0CAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw
+DQYJKoZIhvcNAQEBBQAEggEA4eLEmHf/6DsHBAcRmooFcZt+N7eZJfpaZ9HCldPY
+F7ueAP/xET+DX2fz4R4QN0e8PdWBtT+MN2QY+PSwiGoItIZnv8CSFiDmbZ3wM2CJ
+LVGMaY9Vrm0d1jTwRd6ZXtAIQnfC5Yq9+fXS2GTJyU9QOPxj9JeMO2P+9bET8Lnl
+68B5jhZeB71vJiBkRxR5B8heVHOs2Fge82eHk6dbrnjIa0kX6OUhBP3iU8VciGKm
+JU2KaXJGdwDsAwixhlbrpBxvx+wuM84BrFoX+XWKGo8tjOU+lbdki/SX3ZTUIQxw
+KOpFMDxfv8vZYv2j8SwctNBQoo4t+2762sZhwTPoBuInTzBwBgkqhkiG9w0BBwEw
+GQYIKoZIhvcNAwIwDQIBOgQI/LSs19+CCdmASJAvygLebvz0imSv+aHAG+JFJ1cZ
+wVrOyIYXv/f9Gt7Yxveqxg6jiV/qsbv9UnH0NLCFoQcMI6heWdqKDQ+/SJAU/vfW
+5gkT4g==
+
diff --git a/crypto/default/cms/testdata/encrypted-rc2-40-cbc.msg b/crypto/default/cms/testdata/encrypted-rc2-40-cbc.msg
new file mode 100644
index 0000000..473e0a2
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-rc2-40-cbc.msg
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICEQYJKoZIhvcNAQcDoIICAjCCAf4CAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw
+DQYJKoZIhvcNAQEBBQAEggEAq6n28OkHhwvyHF16s0FCQDsL5TYhq1wPx3T/m6+v
+b26g1aDhj8xtfnNeTf0lt0swJCaYCWwLYafLr5rAIHRa9h3vDvvj+1HZfz2d8dkA
+R8UxnWPpqVi6awKjsHDjY4mqWowme7wj6O180+kokEVGn2/O6NEWH0/qV1t6wHU+
+Je3spkIaiao4jFP+RlDQ+K29Jjxf1iGR7NL1iYFyge7qqM2OhWmvWMUQsKk4//P6
+0HEf2CscNbWERAmuSDygD0vYHaNQZb2Sf1r3x3Yu92Eo2tHDfPQ+Q5ZC7+KYnoeZ
+6l0eTzKMMiNMpUttIPbWLVW4HGH8l5d9Zg3dnsGJrSfqJTBxBgkqhkiG9w0BBwEw
+GgYIKoZIhvcNAwIwDgICAKAECCpTgWHFxjWBgEifW17fQq0Ofu8CLyPRcXJhLu2l
+7BPVaBrajonCxT8AngpycYGCzbmdT4IMTOoWrMQwMEC3DRVOSJ/5OyZoRhEFX5mM
+ThLDsSM=
+
diff --git a/crypto/default/cms/testdata/encrypted-rc2-64-cbc.msg b/crypto/default/cms/testdata/encrypted-rc2-64-cbc.msg
new file mode 100644
index 0000000..cd12987
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-rc2-64-cbc.msg
@@ -0,0 +1,18 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIICEAYJKoZIhvcNAQcDoIICATCCAf0CAQAxggGEMIIBgAIBADBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUw
+DQYJKoZIhvcNAQEBBQAEggEAPO1e04MYq0iBnQY95xOohu5Mpy+RsBkD0XVviMj/
+mCoOcnRXQfIh9WirAEAk2CRTn+qxGZUtozaM9tWguk+2C74pPuoZMJqSsmlPK/LW
+q7QNO+MEn78RU4tbRncTKmbULU4MU2R8IoypSmMt9LjvVm/NqRj/90XvzGekWEgS
+O4iWcx7w7zqiHeroLqejfbjUQOBM9SY2JMioYzm1i208A06cvr4RpQ6mbJxfgOla
+5e/na6h/TnkE6donFbiOoNnufVb+Dl+aJ6aowkIKqxZJR8GyKFKA5ng0RWDh0cna
+c8WW13rvFhElRQ5+6KYaTn2eqOPNreJMHS7OCEb2Mqs+9TBwBgkqhkiG9w0BBwEw
+GQYIKoZIhvcNAwIwDQIBeAQIEYzmcLoeIEiASMBXhqZpmzHUUCeoAS+LzDAggsYX
+eiW/sMAkyjOj09Uh+RbnA2J0xCYdyPOY/IHLyBCqUOzu7eXsISEAvQTd3N4aM92i
+E9jg1A==
+
diff --git a/crypto/default/cms/testdata/encrypted-rsa4096.msg b/crypto/default/cms/testdata/encrypted-rsa4096.msg
new file mode 100644
index 0000000..bf4183a
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-rsa4096.msg
@@ -0,0 +1,22 @@
+MIME-Version: 1.0
+Content-Type: application/pkcs7-mime; name="smime.p7m"; smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Description: S/MIME Encrypted Message
+
+MIIDbAYJKoZIhvcNAQcDoIIDXTCCA1kCAQAxggLUMIIC0AIBADCBtzCBoTELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQKDAZHb29nbGUxLTAr
+BgNVBAsMJEdtYWlsIERvZ2Zvb2QgVGVzdGluZyBSb290IEF1dGhvcml0eTEtMCsGA1UEAwwkR21h
+aWwgRG9nZm9vZCBUZXN0aW5nIFJvb3QgQXV0aG9yaXR5AhEAvAjcPjVD8rmZ57/+zBgO4DANBgkq
+hkiG9w0BAQEwAASCAgAhAt/dzPIA0ny03QIVfrmDnygycDYue34QTUaisCC5tc74Qc4KdySPsfrc
+Vl33kzjhmf1Xelj4GobdQSle5+nnBeZYrprYMlaEcmaXHhCDtHaPgvmt9PntyIrbc6Ir3hi5oiXK
+bl5xxaE16ruo76NzRONrjpe0hTlv1akS+2csHRmFb5fM0kq6rklhcBIWk3s4G0Jyrwi8bH1xNiiR
+wQ6SjvlFx1KCnbh9oX1KuFNMI9rDEg63uvg09lQkZvHYLdD/SMu8oChcm7mNDvvUbR2YQ6qQ5Z06
+URcxBGZzcDXTDdxNmc8RNioOBpjtjKGM7KP0oGwXx1a8mGv3QkH5Ta5prY/BvrGISPEPFxHLXk6G
+sXoX0SdM4yRzPRhZrDKq+ycdp92qbl8BKf3+BN/kQ7Ld42SjcYqdUepownwAI5fKpCxi9vZ3EiOT
+dtB4/TSi+CFjHYik+1cfarK8E0AUf7o+OUgbq4RBBkytfVGlRvmyKVciRxL0MGnV3kyuxhBHR1Td
+BpN8J4BaeNgAgFGo59R5WesFmH0bhurszpKu3pVqW1RhjjSFSfJXPm3JtJ/f2nC/QlVOv17jp9J3
+qCEvYyYBMfrT2Ep6J1/o0+5XG52KrrWcHTY3GZgNvtPGJ32ChjnVih5EQU9gMm60XSME44vsman9
+xPEa64qitq6yo2FxVjB8BgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBAmlRrJdbBKj8Oa2Zj6qoml
+gFAL+8sQV684PH06VDavOhXv4/WGnk2Q9IU8eZ5uVBG8cnXIi3+Ro2cle3+DgcORYT7PJRrqfzcR
+w3E7IMbPrs9Hy7+qqs8lX+Z9Vp12Q+VGIw==
diff --git a/crypto/default/cms/testdata/encrypted-thunderbird.msg b/crypto/default/cms/testdata/encrypted-thunderbird.msg
new file mode 100644
index 0000000..bdb6cc3
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-thunderbird.msg
@@ -0,0 +1,41 @@
+From - Thu Feb 12 14:29:52 2015
+X-Mozilla-Status: 0800
+X-Mozilla-Status2: 00000000
+X-Mozilla-Keys:                                                                                 
+FCC: mailbox://user1@localhost/Sent
+X-Identity-Key: id1
+X-Account-Key: account2
+Message-ID: <54DD2960.60809@foo.bar>
+Date: Thu, 12 Feb 2015 14:29:52 -0800
+From: Laetitia Baudoin <user1@foo.bar>
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.4.0
+MIME-Version: 1.0
+To: user2@foo.bar
+Subject: Encrypted only
+Content-Type: application/pkcs7-mime; name="smime.p7m"; smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Description: S/MIME Encrypted Message
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggMIMIIBgAIBADBoMGIxCzAJBgNVBAYTAlVTMQswCQYD
+VQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBD
+QTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUwDQYJKoZIhvcNAQEBBQAEggEAx+kVQ3B4
+enmA5g5/fqQVK+k9IkdRjWIJHfIgbQqxISXDXcB4dOdbmzZfxQ9x8/zo51yqxC7agVF6mjhE
+/cJw2Z/3dGi6w3Dcu8zU8++oWahwZLeez9fthXCR4VDjZIU5QqxflkTfAnilmE+YEpSMTiKr
+APkxRzzI8qCH0i1A9cjBiBcdaCkROnbwuYz599Z6lr1fVnworj6257aJzkzl6HjWqYCLPyPL
+GSniKR1jw6wN+saa2FIlkwtpT0AjpjrdpLW+PMhVTlBtlzzPlFARBSz+BOIe2KlJua0IEKbz
+w5SpvzSkOSPwjQd8e4xNHnJrkNDmc/vMqOR/H85oLB3x5TCCAYACAQAwaDBiMQswCQYDVQQG
+EwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNt
+aW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI2MA0GCSqGSIb3DQEBAQUA
+BIIBAINc3XqeWpJUHQ7Rjmxm3JUoo60LbTQZPaRkYGeYyKh1skr9puXfzxgab0thYPmDy9by
+lkvM1mvKfRo0XDfvW+7vVziIh35aDAlmlma+wRHaZuRvPDY9bLzGnPZ61s5uLzXx6FXUp+tp
+K748+qTSpQhy0v9TBcuK1gu4lHdhunJVKJrNxD+2iKq6qq1lj3y1qlglEhdghFD1R6lCWfH/
+FU1X/cU0ZBiauhaRzA2Hcr8v0HdavUFg21Kt5oDPFHL4cUCrkO/mvkiwF5rML0ddqtdFr6n7
+TqCbgIspR2JGWb9nCIUsVeU1TJ4uwWLy7rjSDnq+ZMBJbAlfAuPslcYOHXkwgAYJKoZIhvcN
+AQcBMBQGCCqGSIb3DQMHBAhaDVYjoeekoKCABIGoEHoSw16nwsf/pCclaGkkP15haT1qSww7
+whaMl9Lfpk14rgmkrLAUuZyX86YWkzZCD/JebAzOwd/BR7W6dRfHhucQapEBRzNLa46Yc2JM
+g5BRonzGJC77ilxnc9dLe4dIGJ+Yvx4SSrp981yywc9zv4sSZ7V8Zz4xGUBeKVMORKdsU1NU
+DtiCnF3FU8tvF6l/Lx1EtTjKTd0PfTTIzbO8eyP90rOA36oTBAhPqUMu+OE8tgAAAAAAAAAA
+AAA=
+
diff --git a/crypto/default/cms/testdata/encrypted-with-originator-info.msg b/crypto/default/cms/testdata/encrypted-with-originator-info.msg
new file mode 100644
index 0000000..9ccca47
--- /dev/null
+++ b/crypto/default/cms/testdata/encrypted-with-originator-info.msg
@@ -0,0 +1,191 @@
+MIIqVwYJKoZIhvcNAQcDoIIqSDCCKkQCAQKgghONoIITiTCCBDYwggMeoAMCAQICAQEwDQYJKoZI
+hvcNAQEFBQAwbzELMAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1B
+ZGRUcnVzdCBFeHRlcm5hbCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwg
+Q0EgUm9vdDAeFw0wMDA1MzAxMDQ4MzhaFw0yMDA1MzAxMDQ4MzhaMG8xCzAJBgNVBAYTAlNFMRQw
+EgYDVQQKEwtBZGRUcnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdv
+cmsxIjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3QwggEiMA0GCSqGSIb3DQEBAQUA
+A4IBDwAwggEKAoIBAQC39xoz5vIABC054E5b7R+8bA/Ntfojts7emxEzl6QpTH2Tn71KvJPtAxrj
+j8/lbVBa1pcplFqAsEl62y6V/bjKvzc4LR4+kUGtcFbH8E8/6DKedMrIkFTpxl8PeJ2aQDwOrGGq
+XhSPnoehalDc15pOrwWzpnGUnHGzUGAKxxOdOAeGAqjpqGkmGJCrTLBPI6s6T4TY386f4Wlvu9dC
+12tE5Met7m1BX3JacQg3s3llpFmglDf3AC8NwpJy2tA4ctsUqEXEXSp9t7TWxO6szRNEt8kr3UMA
+JfphuWlqWCMRt6czj1Z1WfXNKddGtworZbbTQm8Vsrh7++/pXVPVNFonAgMBAAGjgdwwgdkwHQYD
+VR0OBBYEFK29mHo0tCb3+sQmVO8DveAky1QaMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8EBTADAQH/
+MIGZBgNVHSMEgZEwgY6AFK29mHo0tCb3+sQmVO8DveAky1QaoXOkcTBvMQswCQYDVQQGEwJTRTEU
+MBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRydXN0IEV4dGVybmFsIFRUUCBOZXR3
+b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBSb290ggEBMA0GCSqGSIb3DQEBBQUA
+A4IBAQCwm+CFJcLWI+IPlgaSnUGYnNmEeYHZHlsUByM2ZY+w2He7rEFsR2CDUbD5Mj3n/PYmE8eA
+FqW/WvyHz3h5iSGa4kwHCoY1vPLeUcTSlrfcfk7ucP0cOesMAlEULY69FuDB30Z15ySt7PRCtIWT
+cBBnup0GNUoY0yt6zFFCoXpj0ea7ocUrwja+Ew3mvWN+eXunCQ1Aq2rdj4rD9vaMGkIFUdRF9Z+n
+YiFoFSBDPJnnfL0k2KmRF3OIP1YbMTgYtHEPms3IDp6OLhvhjJiDyx8x8URMxgRzSXZgD8f4vReA
+ay7pzEwOWpp5DyAKLtWeYyYeVZKU2IIXWnvQvMePToYEMIIErzCCA5egAwIBAgIRAOAjyxUSg1OJ
+rWFuelRnayEwDQYJKoZIhvcNAQELBQAwbzELMAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0
+IEFCMSYwJAYDVQQLEx1BZGRUcnVzdCBFeHRlcm5hbCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRk
+VHJ1c3QgRXh0ZXJuYWwgQ0EgUm9vdDAeFw0xNDEyMjIwMDAwMDBaFw0yMDA1MzAxMDQ4MzhaMIGb
+MQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxm
+b3JkMRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDFBMD8GA1UEAxM4Q09NT0RPIFNIQS0yNTYg
+Q2xpZW50IEF1dGhlbnRpY2F0aW9uIGFuZCBTZWN1cmUgRW1haWwgQ0EwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCJsQ3aelMZTnBSHbxWpgYmt7hJ4JbnUavx8FoTSRWjtIwbYLx6UUKn
+eYykIt8XYU6R1XYjChTTSgJ/th0JgG6lBD3ZursW/qGHqS5DUkMWfK8yUMimT1rpCNjPkyWce4jo
+MGTmpPhWgP0qJBQzF5msROVpi6NGBkvCM9TpQJ8GsLGsk0C5tQiTOpwqU6MQ2z0gYTxVA47ZTnYl
+AiEp+qN8cXZP7uFfgen7VIDbw3s1UreE3iI9LDAtMX9ZvVI3sDNpLUPr+tal8Zd3Z1GM2e4n67yl
+Bzh2jKSpOP/fjPUDrEm+yvdzmToPMquclToTPQ5GOld0YVC+xkA/y+Tin6IhAgMBAAGjggEXMIIB
+EzAfBgNVHSMEGDAWgBStvZh6NLQm9/rEJlTvA73gJMtUGjAdBgNVHQ4EFgQUkmFrguGioKpP7Gfx
+wqP3tIAAwewwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI
+KwYBBQUHAwIGCCsGAQUFBwMEMBEGA1UdIAQKMAgwBgYEVR0gADBEBgNVHR8EPTA7MDmgN6A1hjNo
+dHRwOi8vY3JsLnVzZXJ0cnVzdC5jb20vQWRkVHJ1c3RFeHRlcm5hbENBUm9vdC5jcmwwNQYIKwYB
+BQUHAQEEKTAnMCUGCCsGAQUFBzABhhlodHRwOi8vb2NzcC51c2VydHJ1c3QuY29tMA0GCSqGSIb3
+DQEBCwUAA4IBAQAbKm6sVcE6q4jF2O3NVfOqa2ErwAkQI5kPxWZqb7H1tLV3Xg8CYQDffQX+ErOk
+gIAA/PsdW2pyAgpBvAW6wVjVJsLq1U2E+/6CmM9YG+MiY5xS+LsFNqt9WKXeqztj5drVc+/s4Pt7
+4qP/8EIjnMq2jU0+5EsYA7KoLdTYu0JLkGmFENumNzToe+ABEKWcyjrHn0+ING6KZdAairup3MrK
+NtH0/MJkKTWv1rGncRHSA0Oxjz6a7J4yU/R2ksqGNAe5LMrmHErYmQ3BhuKQkvtaQmojIRDpZcf1
+1bt+6oyFIAJi6tE6ByxZxZkz8jiJ5bbpFnofeRT2ShAaJvp8ivubMIIFOzCCBCOgAwIBAgIQTcHn
+SWxTIEBd+ZpWfd4EbzANBgkqhkiG9w0BAQsFADCBmzELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdy
+ZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExp
+bWl0ZWQxQTA/BgNVBAMTOENPTU9ETyBTSEEtMjU2IENsaWVudCBBdXRoZW50aWNhdGlvbiBhbmQg
+U2VjdXJlIEVtYWlsIENBMB4XDTE1MDgwNjAwMDAwMFoXDTE2MDgwNTIzNTk1OVowIzEhMB8GCSqG
+SIb3DQEJARYSdGVyZXJxYTNAZ21haWwuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
+AQEA8+67AQPfRjgxQRFhj9y6Pt/rkHBVNP1mSDexk2YphscvaWSWsgoSH5M17BZsOC6Kqsq4KmRs
+TuzSFusctlJ3v8z1tO75dUy65UjoazCrCOQCx5N+pwlerFSbyM0J3kLf/ynKs279pd39T343qszn
+/5P7orJ+f6sPRpWjnOphfXm6vrZSwCphECGtJL4UjBNCRAz2U2tZwhr2iMzrS0lQHq6y1ZFQiaII
+ErjWtm1soWqHIgH/Ngp/uLNgdeI/YY+qxd/e8OyG6eS/L+VM0l5qPbm/cuw3tSPB7qWIuo+kNIQv
+4pmXhRl13nPTI+jFqTb/ghLpgHlHxAZQreBUF9zHnQIDAQABo4IB8DCCAewwHwYDVR0jBBgwFoAU
+kmFrguGioKpP7GfxwqP3tIAAwewwHQYDVR0OBBYEFESuiJz/WV9Y5OfCdn50CZwBlgY7MA4GA1Ud
+DwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMCAGA1UdJQQZMBcGCCsGAQUFBwMEBgsrBgEEAbIxAQMF
+AjARBglghkgBhvhCAQEEBAMCBSAwRgYDVR0gBD8wPTA7BgwrBgEEAbIxAQIBAQEwKzApBggrBgEF
+BQcCARYdaHR0cHM6Ly9zZWN1cmUuY29tb2RvLm5ldC9DUFMwXQYDVR0fBFYwVDBSoFCgToZMaHR0
+cDovL2NybC5jb21vZG9jYS5jb20vQ09NT0RPU0hBMjU2Q2xpZW50QXV0aGVudGljYXRpb25hbmRT
+ZWN1cmVFbWFpbENBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwWAYIKwYBBQUHMAKGTGh0dHA6Ly9j
+cnQuY29tb2RvY2EuY29tL0NPTU9ET1NIQTI1NkNsaWVudEF1dGhlbnRpY2F0aW9uYW5kU2VjdXJl
+RW1haWxDQS5jcnQwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTAdBgNVHREE
+FjAUgRJ0ZXJlcnFhM0BnbWFpbC5jb20wDQYJKoZIhvcNAQELBQADggEBAECTaBMbOoFKVQyHLsYN
+Tx2J+WI2LWMcZhPBixMFJHLVu9Os6cH43Fru/44ewy/CJVgxey9L6ti4MW8LuTrVnsC+IGn3Cx7F
+hYEF+Kukxd1V06C9n/SYp1pOIQkdvwwM/7GN02Bcr05tdwfFbYKn4WLQsVxsiuFpI7HssqWSf0fA
+eioUJXKK32P3hvPEVkhXGIkuXE+sA0yCGVZMO7rxWB4dkqTAESM2K6zXX5gArghjHEg/gkQXOZoM
+arC1VDcRWGxL/kiw7hgpue28DJypf4pB9IlvnZx7bpYWRHPYHzftNt7wi4rBSWUAtLQLpsOEbMte
+u7x7Ir6+cdUBR9RlZ0EwggVZMIIEQaADAgECAhBvnurZBa4WY/FPP6LqFfSUMA0GCSqGSIb3DQEB
+CwUAMIGbMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQH
+EwdTYWxmb3JkMRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDFBMD8GA1UEAxM4Q09NT0RPIFNI
+QS0yNTYgQ2xpZW50IEF1dGhlbnRpY2F0aW9uIGFuZCBTZWN1cmUgRW1haWwgQ0EwHhcNMTYwNzE1
+MDAwMDAwWhcNMTcwNzE1MjM1OTU5WjAyMTAwLgYJKoZIhvcNAQkBFiFzbWltZXQzQG5zLWV4MTMu
+Z3Rlc3RleGNoYW5nZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDETQpKrUmo
+ZGbNiJCN/Pjhj75GmgVt9h+YE5gW1A2wgU5Ofr1FCKU8qeJezakF8GCGChSoHR4GXNnoe/kExYpk
+uRBFwXC8Mfln4mGE6iqriOLRePip2IyHf+OBwmVQnlT6CcaG8zI0ul+DwFgVRtwhIXDJrbl/O5V0
+C6UoBXylj7y4hq0+nlx96e1Bh1WHg/cyqjCVQ3FTV/9Ogp/Y4cDNYLISvb6/OA+Edef8JzDuh4RH
+Jf2b5cZdS4izS4368c7Zwv9sQYrj3Tc/CplrkrCB9UYfUJqk1qqcKv+0f+re4r6AsLErveri6ASB
+YXhHVCSDbKmy/YarcGqccXFk4c1tAgMBAAGjggH/MIIB+zAfBgNVHSMEGDAWgBSSYWuC4aKgqk/s
+Z/HCo/e0gADB7DAdBgNVHQ4EFgQUf76LpwQqReGFsj17w239gNIMdrUwDgYDVR0PAQH/BAQDAgWg
+MAwGA1UdEwEB/wQCMAAwIAYDVR0lBBkwFwYIKwYBBQUHAwQGCysGAQQBsjEBAwUCMBEGCWCGSAGG
++EIBAQQEAwIFIDBGBgNVHSAEPzA9MDsGDCsGAQQBsjEBAgEBATArMCkGCCsGAQUFBwIBFh1odHRw
+czovL3NlY3VyZS5jb21vZG8ubmV0L0NQUzBdBgNVHR8EVjBUMFKgUKBOhkxodHRwOi8vY3JsLmNv
+bW9kb2NhLmNvbS9DT01PRE9TSEEyNTZDbGllbnRBdXRoZW50aWNhdGlvbmFuZFNlY3VyZUVtYWls
+Q0EuY3JsMIGQBggrBgEFBQcBAQSBgzCBgDBYBggrBgEFBQcwAoZMaHR0cDovL2NydC5jb21vZG9j
+YS5jb20vQ09NT0RPU0hBMjU2Q2xpZW50QXV0aGVudGljYXRpb25hbmRTZWN1cmVFbWFpbENBLmNy
+dDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMCwGA1UdEQQlMCOBIXNtaW1l
+dDNAbnMtZXgxMy5ndGVzdGV4Y2hhbmdlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIYEvm8uBVaFq
+1xqE3gnuiSJDBodT8KsYklOvyDhskOuEsQlkX/kr7fBF1qB0a2tFsnuDJWo7MouEoPfQY0V+frtU
+S3E0T2dfICCamqTbiSupz/8oc7ty0aI3joKqCeec8h7oFDPwBRllqJ7cSw4rU4kiUb68UU3g95DJ
+NuLLNaEZPPjf8fQm5IB24Cb/+0wg4MsLQ6wmWu1lQRKvqCpmLXLXOsmkkO3HEHd43RifkMZqKU3D
+DziRXlddWSGo3CdYeMY1zw3iPVEU/yBGq/yYyyVOGByK8OC7/zSPVX9YFVMHKmjjC13JYHFEzpwi
+PkHhGe9MhOnqcuiR927lZ7BfPDGCA5owggHJAgEAMIGwMIGbMQswCQYDVQQGEwJHQjEbMBkGA1UE
+CBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01PRE8g
+Q0EgTGltaXRlZDFBMD8GA1UEAxM4Q09NT0RPIFNIQS0yNTYgQ2xpZW50IEF1dGhlbnRpY2F0aW9u
+IGFuZCBTZWN1cmUgRW1haWwgQ0ECEE3B50lsUyBAXfmaVn3eBG8wDQYJKoZIhvcNAQEBBQAEggEA
+yCJHZXhzJNlkJbXYj2xLorWLL2u4so4+e9ctStKO3JX8lVG+OmrdiFlIwEa0cerqrKokjhsEeXJy
+sXctrKdD1ObXVJ3v7qke4XrGgv9Tzvnt3/46UWLlPU4vix3Ii1f2kCmlnI9OBbdfuR4mIth2rqcn
+2JS0yFjzw+VLp3pmy065szsvQsEw4eOaehFKyNK314gUPiFqEADh8T4JbPnxDp4X7HB27tP8L65o
+hf6heWa1ecengtfXKLuDDL1vrHx2Z3yKjjV/iDo0VcSTBXBoyzP/JG0BmVk+lInOV/GmItwypXBv
+oZsylm5a94vLIqUQwB/LT4sUt3r3+55xFmiMBzCCAckCAQAwgbAwgZsxCzAJBgNVBAYTAkdCMRsw
+GQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcTB1NhbGZvcmQxGjAYBgNVBAoTEUNP
+TU9ETyBDQSBMaW1pdGVkMUEwPwYDVQQDEzhDT01PRE8gU0hBLTI1NiBDbGllbnQgQXV0aGVudGlj
+YXRpb24gYW5kIFNlY3VyZSBFbWFpbCBDQQIQb57q2QWuFmPxTz+i6hX0lDANBgkqhkiG9w0BAQEF
+AASCAQABLaEoK2bv2KGFMGtNyGd9xZkmIUnYS+0JIymsVHUHNqiNTWbPtTrmxsIUPaEfOAQRckMR
+TOlhiz8vgupyHjIxFW3toMQoLzwag+XqJrTkwpeOAe2hZqwBbk0o8FEsdnRv8W6Kf+PxesOphwNP
+z8dEzBwilcNCRWLcYTuT5KoqV6GVdjpOuAANWxRfTklorCjsjRcLVxe7QCurUTbccSTNrhmbZ9yC
+8kGdQQs1d2VMSQHbTvkWeVV8O1zTPyKjOJvMMiSIJCXRewA0wEqdO4PuMlokzlFIua9uVWhHXNYl
+dQpvjDGEmpUkeVg8y188ZWY2pMdZvSlQJvdQvpV0Nao3MIITDgYJKoZIhvcNAQcBMB0GCWCGSAFl
+AwQBKgQQIi0IuEwxtQGvHvt9pOZOrICCEuDnEHbW3sNxPqw8HeZ6GF3T2viy60WP3USK5m4IrLJv
+4m/oZnKGXTy/QjsQGVbKnMfCzhtiaGe/GKEER95DTgC/TcFshSQGFkWJrQXL2slmF9bfCYy9UF9O
+tUHZdM+MD2qoQuxgExDkbTo7WsDekDIq7FDq0vFOQY1e7VczauLCP5AQm191u8dAvpZPzVl6pI2Z
+fZco5eolPo/domjsOfYhqckKnzcrNHD4gS4LjhvgwK1IghCnQcxeseTPj4yjQXQQJIFOiYLEcNDB
+dL9YtFo42hPmeWZj03ePY+o/iecG1KKdQJxlnjNxfCrdbwgq0hUzYityUyx/2rx1cmP7KpOKTCIZ
+PBTLAN5XT5+LucGHVDUvGwpmJIh/xoA9rAk+dnG4O/OVwZ+5JBfkEGcXDNMOdtjUqP/Y0YVG5hOz
+KjSznW99DPONaCRHho9LtShpREJeNmAWqflShutSdy2uMDf1anbTO2RVkpuycHHDmtSxdjflr5lO
+CARk4n2MgitjdBSY4VZfNWj26DBbOPlNAEodTMSR0DEzWrPmQ+xY+2uhc19ZD9o4ksyQ+BgmvyFC
+JeDv/FqnsFRTvphMl9onVjbyW2I58dU6WLSZlkZjzje4Hdkhy95TDMNzTueWGGIUCs9a7ebaabut
+7+V4sw6ljj3DfpevP7jLydnog9pGo3QxFFJCYnGlreB41qhU5DwzokuSgoLFAVROEN4hHnOPEA8S
+7GwtbTt/U1QGi44k4Rc8SIBu6EuYw1vgTlGg1BIyQ9PraEG/O7QwKiexn4CKQaAKRv/wWZqgz1Op
+tDb+mbEnerZj/hwSjGPhmASIfDUnxJXcuOz9wqLTwMTqQ4DuHRmxLBF6Ev9m0JenBTtw725jN4kh
+GgfolUCLwZIK+EvkG1Imvf1Y+9/hCYttlDoTIzi+hAndStsov9iKCB261HSTgVXlSjq+vmL+l9gs
+YhY0yq4ca2YRuefDI/9S0XXoIccco14ksKnh+WGEmEYtSiNJzn2Y5srU75J5zMogpKqYppBLpnqp
+c8TNNwVmhr6EJqXuyGBUg3JOtBB0FPyD3TGKvcS20TbPrUaMFb1+ZB/RJpAwaQM5N6uzdIn24XfX
+ErpJ6TpVjHxizG7UNbb+q+XWpO7EYmIhOj2bvcikrJZj6O8aSVrMuNwlf92l0UjApkZ6u0yMJuB4
+SgKCuoaKrVaNLuz0Jm/kaMVjaPIW10hbgNVWnMpSjXqUOBFmScH6wg0ShtvZ2HEML5yx/Z/tmzXR
+Y9e5R/fSg/vo9BDPEiIZC0y3fuCC4pg1X6Iix6EjAgideMOBeJH1/ym16TrAPaouYuGeQi896xWJ
+zj9eukaNsxxWsf9gHFvVguX/7XW9UwNU+BM3DypMrxmAnDioY5g5NrXoi4b1yflZc5biTd+7ysvq
+Iv76GgJcxwhJMYaBZ1kCxCIZna05d139XzQ3Q8WSZmSEY20zNI+swRl58/N5/xN3YgsKd3ISHs1a
+4HSFbDI6g57OgboGtdtj5CWz148MyBGBTPC802IUkGfvVCWyPaFdfc4pwKDvb3aZ43Y9PX3bb3cs
+Li08sKslurE1DWhxqXQlera5qX2QqHAgtp8AfxII1HhwXAbHqF7e9xiyibWkdDBJts4baiFIavlx
+3UhYOgCi8KkqLXMPHavoSFcvX1k7rW6Lcp48EfR8pWA8EZ7QU8NjdoWmP6XrRCW64WYt5uCEOM6M
+EGYDSJz3hJ4U5X+QHrc+A3MkkXvNXox8kByBo8g/uaX+GsMD+EA6PEF3gIIH/bswC/zB39z3tjM8
+8v238MwiS2+Qhg2TG9/xKMuWoVUqJbxKU3n1aJFVUUmt35As4O0BqvPwv1nQCdtEhd20NUMNQecM
+eijwE64ZbuXqaexG3Amq/DbCzUbDpfYkdEvS4dcDTPK8htRzAHKRupXZzBIqgBvbf0I10eUU+/ZV
+uX0XwWecSuBbyS8tc+oEA+P+ojNP8GlzN8JKdskWeDLilU+JaMuplea5acLUMoRZVPYk9zjN5be7
+yIZOh5pqyEMDFOG4BdpEnXX/akhfyLk4LlE8Svc/gUofP9N3deJi+l+x+MniuTaP0Q+eVDjQWbrL
+hJnkBK9Dr194dCtEo9ncC03ff9Lmu/4QjDoQRzcwzocpDwq95N4XCvZgOgjDUSdVshCiedrClF/F
+lpFNmUhIRsy+3TcWDESvv6DnoIctWUoF4gH6G8KKfqC2/6KZjkmXshGk/S93y++pfYwhaO3Qjtpt
+dGvpeHYh+4YaKomrral+oaDca4+ZQCwYnwke3vKItOgHQ9jCh6jbJyExGYE7eIVvV43i5x0r1XZL
+rjyFpJ6SSUuId4I4iW5/2+hejWQJfWGBoDiDvDB3VRYps1oT96+bXib0p+AC5uD6Ak6N473E/VDh
+G5++nnfPPkETb8eOqBP5Mvqs3qe49+VE8U75Uw0Up7JT80JNctIVmnlshBTU2hmZwOrpOIyTvOyd
+4Z4En311ZttqbVmTYU8Zsqj9sEfbr2Hm1R4L96y/hAcmJGmz4itgNmjkf4PC5EVAaunJOTS/wAu5
+61KiiFyuK6/urbNJ38tQSjZey1LMaPl9Bd9yu/rswQi4wcS+mSMTzazAib+2pqHvXS8he5oeiCLv
+a0TfqvSowLFk3sbHHWj1GbFvLqP6r6aw4V+LQnkU+hO8x80TBykqEldFOa8+j5S7mO4g/W2Y2zpH
+CMnMp9J+siQrEVgiKaPV84G9INagFRA/rfLKAFOBD5lclmzIE3AqYWjKSBAzH1NXlcNVZQe4RkdB
+kyyQzlFmTcwIUBQAExNwzUBwIalBuondEHiJzu1aQKrmfBeTQLZAio1J7PMFMuypWod+E9qdNLLa
+Vt/OOMJq+0p8KkoOc0cTGneDHDpxQGYLFpmU10FcvvrfnxsohRw9lIaI269ZDsAvKHKy6if5Iisw
+GiwIUoohxZNyOPpSL+Yrm63tSlGQJ1kyB8aKemVhLFiFzqiN+ySGmCSnuNTiTlG6kDn23lsnQXpU
+JwCURDNOmKCWN2MPSqwhDppVy/POQzxRYHHnJimhURNxVO1wbBCKOJQJ0sZASzjtPEiTQ6+n6j57
+QpQIxuX70uLap+7zrGtxySIffjmdIT3kV5ZUF7/acqAcjzN/ODJuDa7PaLKiqM9PkdlH9JR6uV9p
+ANsno/S8enLqzxld4g1LPacwJAs7M+t1gqD/frk4QYEZ1lrCrQ4ydYY+jQur1Vr6VhRqSdzVyAh3
+4J69yoeKlo7X17DOtbScpF9DUDVVpBpbERuarEtPPqRZuh+un6XVpTCmX68tayakQg373XsOv6aG
+zVg/GsWo/LFeTqLB/MbE4hLmL0WTtbeTbKE6baqxtA61mQ/pVzMoCBO/dzhBBeIpNa0Xz9skeriz
+2b9K7JfsBdLwP1J44j2DOIJoUc8N9L4ixkYIMF2O3QW9xkS6T0lwAiRdgCwbkfyzu+mxFfKPPSOe
+OQ3JxJGQxT4Qb8K/ofzOD9Css4D3CT8GLEEC00FnLb7+PWBGr/5Xfq4iP6dpiNZZbpyyYdo7Crqi
+VfG/rr0UXp3YT/12MA0rXqMqDiTZuTZmKmPhMdz8ZSxrssKpkshaRWfE9EY2Q827CYeWDYl2hedB
+IExVVgNXLx8icsD/LXV60qc5ulnFGGin3/NhyWqNYndkOfK7x9qnkMMFwrZ8Mc4c9PmGcXxp6AM5
+APrr+r3EXrgLTlOhWwC9rllveqhQmABhxdMieXBc1JdHhsHHJePaTUucCZHTlXI0iJDo4E6KSd9b
+ohGvoFjK8sgubSomFXl6MuIocJEm3tZl9yzrvshVjfWsfPerDW/SHIZ37aUwrr7WSLDE739wPiiJ
+qEzo54vPUCdm1GtD3BQFqqv+nucLujCm8sZkk+gW/0/NoaP7OKedav5ZRFkYjBjl+44sEMM8iyyG
+0lkHQf1rakq6nWTqy/mIJkrwOmII2tAvC/12VdA9KvWQoXKA/0pY3ndZMr2kQiqd1YL2If8ITSCO
+dV7mZ6aAgEFJ3BmxxSGCdF8fs9LJpvNlD4i+VXqwuSOM2tbOmftGJBIUInEfeUN8ZWppfE9Q7C4/
+PHwgqaQ0tb1C7TvgxcLfYN6C8kgpGDuP3deibcOeR/1/wDHgrkdzufktu6nlKbqzy8KgcoIIuWgm
+NCS+XtI8x2apX3Xz9XASWfqC1Dgtt69bnfBv+zyikrKc0Yb0kT7qHw6D3vjWXmVL5/m5GeLFkFmU
+9YZumpi5pH+BqWVHdIi2nHdDgyo8KsN0n5CJDiuaNzjzIuXwBGvE2TpE6PxlCV0VS76oPJ0h9mE4
+M/sSRpEkrLM3Sg7bB58y/KCem5rwHAM88cjDU7h/hOKXWlpxhjX3LyHJExEArtyicIrDM+abbm6t
+ZzKfkYIQ2oTFq+wQSEI25qkjSfW6yc54zGDmgGc7QbMTERgKbq2jQLyICT3SIYvavhcdxzaSd+di
+6Wnm49j9BUuieQGduHNHXfniC2jswFpMr4COOqTrHPF9rGMMwbWKYKrF3i7ICtqoX8Ka2Vxi0fL/
+9jP5UJ7OBFmVIBsBI/UUW0evCOn5NU1QbhSY+gkOHaR5QcXDVupWSeyWIhGXg8BSwfkQgXP9AMfb
+fkhwIo1RYQ3n+x8jvtLuS52Y4z+AKX79tLwi8viYAJ/VFd4ypyrHPE5F67nD2RXQE+ASXhf0ZOwQ
+GP3Bmp2fbHoPwDBzL2d9t1hkza6YTPScsOfWcYDsA0MV5zQUdkJvMTA9O6x9kWNl9ucbpvP10lGR
+nlo1xRZ0pb7kEk8wWKFnT4zv6VXO1umeQ6xA5d9X3+eq5tmM+heRahuyUTLref7yomWteQkOiVpm
++/45wdpEhy1BTO1Vqr8yWX7gtLTBUwSR2l6ePksThZIlNN0tyNZNChhfyNJyGAP+cWDLoMX4r9ke
+HxBc2VnwdK5La052i+3gsLUH7833ONyyz2ujDiWWJmjDxywvMwAtczRDT1z0O/VD8KimGeoLvJ6F
+0lwd4qvId6BWmp3rUB0uCWDdIcs3FMCVqR8pHFZxFDXMdrUDxuENpsOOi8lc1o57YDIM5fNgh7aG
+yBsy2/n+rYx7g3JqXf0GhpOq16LDy2r4bEnERVhEPI1yG+opBX8vFc7VMaNqSyx7uSWo/cr+IYsl
+3LD9d4sMnBUV8M7fglETIuw6SiIeuEZ2Wr+mxXx5kSs3/2dcBG3k47Z1BTfL00tXGNuEw0/8fxrK
+T20T6b7R01l5ClCLR1Z6dKZ0otgcw/dq5FwgZ/dNKgcbqHMpsY2NJHhCpFJx6qP/uriysuXvJVmE
+VqjU5e49V5+aJfrLOy7q84Sh9Yhr6zYKkyPRq5PGmvZUgIx7h7TnoRGtB7xPr0goGJYwlC7xTt04
+OA0TO+WsXIok/JRL2BbF2Tp1fDpFAKIJlPVm6Ay96HfaIH+lZH4duf91IkN1GOg+sGL9ZVx678Zk
+0SwSelBnXQH/KW70fUma5iOMRgiZVvfZ51lpPBix3uDcgmzBCS6b1OC7YLj22lKHiIQ0jBUkgv+X
+dqIebPweHnfPqDy2r0KMtdL1Zq7nEl1pRRih4bWnY1Y2TekYmp0okZ3Md4xXUzQ/Q5ghWDtnZDPf
+dMK3PDJmPFEZunCyqRBbrP2j4VzG3xCX366mUtj00Zkrqf5iDVV2ndmucTWjZTMfAtLE8aLyIc93
+r6sY512ut4rJ8mYxZRFM+i+XPSi24JLvz0MW7m+50CBT9ynw+ZJ14WvXAcRGehZUY9oiUbj6aH4s
+cRFSrm49cNprcQaFcn4mZu5VONrOqxvsyOcIslK1xdXLjuMaYgtaQFjL5FYTNUW0MgpUVZItWXpX
+kfCHTiwy7Y83eXhyg85JaEwkfTa76oqjebxgeL5A0HdJbdhKQDTXJc3ngBsJeteoAZW2NJkbTEWs
+pZJcOzt8+mUhzhAj/o4DlYqsEd+ysjFI0okAIsrAUX/IPjN8YSB4knTqQrceguxnkhv/YXoJTaWg
+QUAvGueKrbQV79niBd/zD3fXiZqT03BvkkwpKSmzo80K95VdWmdwQAf6r5pbowaocdh5UqjKSjkU
+Iuzb0jgR7nfglSStybC27ID5eH2ZsTDRnPfPDTQyTG7YAM3h/7Q+edtcjsnoUKSZDcdEWvNpayXv
+0aSjoQJy1F0YsOrntLShMvyeQV8ZL/TU3nRK2FGTHDw2tqRFx7ONjqinRmgeYr7yMvmd1Klv/sF8
+1zFa7AInoWUCTBW305YyTgfZIgudfkzs+IuzqL0qfKZM83VR87LbI/b1kq68wvgzjMsNTB12U7L9
+lCnjuhow49uFW9E1bkQRaQCGNaj8loTtTfiQv6o8Kbhququ2S5/2fJWSiyENPSQmIYxrLm7mrAh1
+xGFyq27+V+c5nWUaaWx7XZcYnneFiOWAi1xrSe8rxPHyXupnQ4Lnxl9a9+is90OImDkEUiXgA/0c
+H983BKWx3A5ripsMig==
diff --git a/crypto/default/cms/testdata/message.txt b/crypto/default/cms/testdata/message.txt
new file mode 100644
index 0000000..a3f552b
--- /dev/null
+++ b/crypto/default/cms/testdata/message.txt
@@ -0,0 +1,2 @@
+This is a message.
+There are many like it, but this one is mine.
diff --git a/crypto/default/cms/testdata/rfc4134-4.1.msg b/crypto/default/cms/testdata/rfc4134-4.1.msg
new file mode 100644
index 0000000..36c695e
--- /dev/null
+++ b/crypto/default/cms/testdata/rfc4134-4.1.msg
@@ -0,0 +1,23 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIIDlwYJKoZIhvcNAQcCoIIDiDCCA4QCAQExCTAHBgUrDgMCGjArBgkqhkiG9w0BBwGgHgQcVGhp
+cyBpcyBzb21lIHNhbXBsZSBjb250ZW50LqCCAuAwggLcMIICm6ADAgECAgIAyDAJBgcqhkjOOAQD
+MBIxEDAOBgNVBAMTB0NhcmxEU1MwHhcNOTkwODE3MDExMDQ5WhcNMzkxMjMxMjM1OTU5WjATMREw
+DwYDVQQDEwhBbGljZURTUzCCAbYwggErBgcqhkjOOAQBMIIBHgKBgQCBjc3tg+oKnjk+wkgoo+RH
+k90O16gO7FPFq4QIT/+U4XNIfgzW80RI0f6fr6ShiS/h2TDINt4/m7+3TNxfaYrkddA3DJEIlZve
+p175/PSfL91DqItU8T+wBwhHTV2Iw8O1s+NVCHXVOXYQxHi9/52whJc38uRRG7XkCZZc835b2wIV
+AOJHphpFZrgTxtqPuDchK2KL95PNAoGAJjjQFIkyqjn7Pm3ZS1lqTHYjOQQCNVzyyxowwx5QXd2b
+WeLNqgU9WMB7oja4bgevfYpCJaf0dc9KCF5LPpD4beqcySGKO3YU6c4uXaMHzSOFuC8wAXxtSYkR
+iTZEvfjIlUpTVrXi+XPsGmE2HxF/wr3t0VD/mHTC0YFKYDm6NjkDgYQAAoGAXOO5WnUUlgupet3j
+P6nsrF7cvbcTETSmFokoESPZNIZndXUTEj1DW2/lUb/6ifKiGz4kfT0HjVtjyLtFpaBK44XWzgaA
+P+gjfhryJKtTGrgnDR7vCL9mFIBcYqxl+hWL8bs01NKWN/ZhR7LEMoTwfkFA/UanY04z8qXi9PKD
+5bijgYEwfzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBRwRD6CLm+H
+3krTdeM9ILxDK5PxHzAdBgNVHQ4EFgQUvmyhs+PB9+1DcKTOEwHi/eOX/s0wHwYDVR0RBBgwFoEU
+QWxpY2VEU1NAZXhhbXBsZS5jb20wCQYHKoZIzjgEAwMwADAtAhRVDKQZH0IriXEiM42DarU9Z2u/
+RQIVAJ9hU1JUC1yy3drndh3iEFJbQ169MWMwYQIBATAYMBIxEDAOBgNVBAMTB0NhcmxEU1MCAgDI
+MAcGBSsOAwIaMAkGByqGSM44BAMELjAsAhQJkf7r0mn1GLfXzVX0geoqQmqtAwIUOgfMwyG+4RpL
+fz61Ddu6HOq8zYk=
+
diff --git a/crypto/default/cms/testdata/rfc4134-4.11.msg b/crypto/default/cms/testdata/rfc4134-4.11.msg
new file mode 100644
index 0000000..926c40f
--- /dev/null
+++ b/crypto/default/cms/testdata/rfc4134-4.11.msg
@@ -0,0 +1,37 @@
+MIME-Version: 1.0

+Content-Disposition: attachment; filename=smime.p7c

+Content-Type: application/pkcs7-mime; smime-type=certs-only; name=smime.p7c

+Content-Transfer-Encoding: base64

+

+MIIGiAYJKoZIhvcNAQcCoIIGeTCCBnUCAQExADALBgkqhkiG9w0BBwGgggV/MIICmzCCAl

+qgAwIBAgIBATAJBgcqhkjOOAQDMBIxEDAOBgNVBAMTB0NhcmxEU1MwHhcNOTkwODE2MjI1

+MDUwWhcNMzkxMjMxMjM1OTU5WjASMRAwDgYDVQQDEwdDYXJsRFNTMIIBtzCCASsGByqGSM

+44BAEwggEeAoGBALZJGD6KRMEpcZRMAcQSwXp5y1RNqx6B+8ZMsw6UCQbrAdSxyHFLx0XA

+UCVdnPza5G3T4oZIhIJ9uhWVShb2Ru3d9pjSu36KCoq6Fnu5UAFIk4vrJRVRl1Xcj1MOEK

+lQ/HC3zTBU/dreqKoitaGvi8wCiOeLcF+5reEI1G0pLdbpAhUA3cEv31POCzRgdz4CpL+K

+XZi5ENUCgYAM7lebS73atgdqdDdPVX+d7bxhDetGWTxWCytbDJHOpWJSacrhbT69v/7ht7

+krYTyty65F4wasjCKdnESHC8fN8BzZtU5dc96vDskdWlH1T0R5NVpzqn9GUR+pQhacSOuK

+eWG01S9TIkRjH4a4o1gGJfgpwO+64HXwQsRjZVKbCgOBhQACgYEAmYd0JwNmoLHArdwsdb

+vhbESc2iFtTUdtsWIJ6diuHvI6tJSxo456m3FOAJTJtCVOuWCWGSQB82IM/nXA+87YaADj

+/dVwT98jlhkGlPSxYY86V7EIEaQLJiXwUnaB6gtiDZUq5oa6crKnUIMLqifNG6lNiZrXjR

+g5hD+LxVZNgHqjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1Ud

+DgQWBBRwRD6CLm+H3krTdeM9ILxDK5PxHzAJBgcqhkjOOAQDAzAAMC0CFGup8E56Wnnj+b

+49K8kGN+kRF6ETAhUAjzRpKouxPAN5lDJNEh/OiftGsjswggLcMIICm6ADAgECAgIAyDAJ

+BgcqhkjOOAQDMBIxEDAOBgNVBAMTB0NhcmxEU1MwHhcNOTkwODE3MDExMDQ5WhcNMzkxMj

+MxMjM1OTU5WjATMREwDwYDVQQDEwhBbGljZURTUzCCAbYwggErBgcqhkjOOAQBMIIBHgKB

+gQCBjc3tg+oKnjk+wkgoo+RHk90O16gO7FPFq4QIT/+U4XNIfgzW80RI0f6fr6ShiS/h2T

+DINt4/m7+3TNxfaYrkddA3DJEIlZvep175/PSfL91DqItU8T+wBwhHTV2Iw8O1s+NVCHXV

+OXYQxHi9/52whJc38uRRG7XkCZZc835b2wIVAOJHphpFZrgTxtqPuDchK2KL95PNAoGAJj

+jQFIkyqjn7Pm3ZS1lqTHYjOQQCNVzyyxowwx5QXd2bWeLNqgU9WMB7oja4bgevfYpCJaf0

+dc9KCF5LPpD4beqcySGKO3YU6c4uXaMHzSOFuC8wAXxtSYkRiTZEvfjIlUpTVrXi+XPsGm

+E2HxF/wr3t0VD/mHTC0YFKYDm6NjkDgYQAAoGAXOO5WnUUlgupet3jP6nsrF7cvbcTETSm

+FokoESPZNIZndXUTEj1DW2/lUb/6ifKiGz4kfT0HjVtjyLtFpaBK44XWzgaAP+gjfhryJK

+tTGrgnDR7vCL9mFIBcYqxl+hWL8bs01NKWN/ZhR7LEMoTwfkFA/UanY04z8qXi9PKD5bij

+gYEwfzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBRwRD6CLm

++H3krTdeM9ILxDK5PxHzAdBgNVHQ4EFgQUvmyhs+PB9+1DcKTOEwHi/eOX/s0wHwYDVR0R

+BBgwFoEUQWxpY2VEU1NAZXhhbXBsZS5jb20wCQYHKoZIzjgEAwMwADAtAhRVDKQZH0IriX

+EiM42DarU9Z2u/RQIVAJ9hU1JUC1yy3drndh3iEFJbQ169oYHbMIHYMIGZMAkGByqGSM44

+BAMwEjEQMA4GA1UEAxMHQ2FybERTUxcNOTkwODI3MDcwMDAwWjBpMBMCAgDIFw05OTA4Mj

+IwNzAwMDBaMBMCAgDJFw05OTA4MjIwNzAwMDBaMBMCAgDTFw05OTA4MjIwNzAwMDBaMBMC

+AgDSFw05OTA4MjIwNzAwMDBaMBMCAgDUFw05OTA4MjQwNzAwMDBaMAkGByqGSM44BAMDLw

+AwLAIUfmVSdjP+NHMX0feW+aDU2G1cfT0CFAJ6W7fVWxjBz4fvftok8yqDnDWhMQA=

diff --git a/crypto/default/cms/testdata/rfc4134-4.7.msg b/crypto/default/cms/testdata/rfc4134-4.7.msg
new file mode 100644
index 0000000..9611320
--- /dev/null
+++ b/crypto/default/cms/testdata/rfc4134-4.7.msg
@@ -0,0 +1,24 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIIDlAYJKoZIhvcNAQcCoIIDhTCCA4ECAQMxCTAHBgUrDgMCGjArBgkqhkiG9w0BBwGgHg
+QcVGhpcyBpcyBzb21lIHNhbXBsZSBjb250ZW50LqCCAuAwggLcMIICm6ADAgECAgIAyDAJ
+BgcqhkjOOAQDMBIxEDAOBgNVBAMTB0NhcmxEU1MwHhcNOTkwODE3MDExMDQ5WhcNMzkxMj
+MxMjM1OTU5WjATMREwDwYDVQQDEwhBbGljZURTUzCCAbYwggErBgcqhkjOOAQBMIIBHgKB
+gQCBjc3tg+oKnjk+wkgoo+RHk90O16gO7FPFq4QIT/+U4XNIfgzW80RI0f6fr6ShiS/h2T
+DINt4/m7+3TNxfaYrkddA3DJEIlZvep175/PSfL91DqItU8T+wBwhHTV2Iw8O1s+NVCHXV
+OXYQxHi9/52whJc38uRRG7XkCZZc835b2wIVAOJHphpFZrgTxtqPuDchK2KL95PNAoGAJj
+jQFIkyqjn7Pm3ZS1lqTHYjOQQCNVzyyxowwx5QXd2bWeLNqgU9WMB7oja4bgevfYpCJaf0
+dc9KCF5LPpD4beqcySGKO3YU6c4uXaMHzSOFuC8wAXxtSYkRiTZEvfjIlUpTVrXi+XPsGm
+E2HxF/wr3t0VD/mHTC0YFKYDm6NjkDgYQAAoGAXOO5WnUUlgupet3jP6nsrF7cvbcTETSm
+FokoESPZNIZndXUTEj1DW2/lUb/6ifKiGz4kfT0HjVtjyLtFpaBK44XWzgaAP+gjfhryJK
+tTGrgnDR7vCL9mFIBcYqxl+hWL8bs01NKWN/ZhR7LEMoTwfkFA/UanY04z8qXi9PKD5bij
+gYEwfzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIGwDAfBgNVHSMEGDAWgBRwRD6CLm
++H3krTdeM9ILxDK5PxHzAdBgNVHQ4EFgQUvmyhs+PB9+1DcKTOEwHi/eOX/s0wHwYDVR0R
+BBgwFoEUQWxpY2VEU1NAZXhhbXBsZS5jb20wCQYHKoZIzjgEAwMwADAtAhRVDKQZH0IriX
+EiM42DarU9Z2u/RQIVAJ9hU1JUC1yy3drndh3iEFJbQ169MWAwXgIBA4AUvmyhs+PB9+1D
+cKTOEwHi/eOX/s0wBwYFKw4DAhowCQYHKoZIzjgEAwQvMC0CFQCJw2t7VvfDEgBl8Tf1xF
+gXjRFXgwIUCw9DOqrs3nphLIyc9UGZpzwgw7c=
+
diff --git a/crypto/default/cms/testdata/rsa4096.key b/crypto/default/cms/testdata/rsa4096.key
new file mode 100644
index 0000000..cc29e3f
--- /dev/null
+++ b/crypto/default/cms/testdata/rsa4096.key
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCgv+Rzkh3ZmXpI
+uLUsXsN8IMxQt9y0Rjt/IfSZT1D4ydqZTHsBBABmYb2AAzw9i9Be4ps9mBDtiok1
+XmKgljy9Y6nYdqTxmvqKFAYsiiEQsxWBvY+aOoS4HO2WjXGc3Lko2H+HGowPbDve
+fSt7NWGJaEyMOYXfhMj/jmmNE/4X64tDNTGShw/Jx2ZSfUvJzO7MQZ04pSt6KxWr
+21eGgItBT59lvEny0U/LU8MNKhCRvmz45GWu6Bi6cwxPZ2I67gjafsk4OoJqiEXR
+l8Zz3RItgC3wLYO0o6piBjX5W2pX+aDlKyH9O497TMhnRN7Q5L+z4HPyBiPhyu70
+40OaxNFMSkBBaYG835b5D2w5EObKSOCJQbHX6K15gb3BsOtWXrViUPuCLgb39aEN
+0v97/tk9dN9+DS58SPLMY3+HU/855WZAFQm13hSlhwLlxvH5PHxeoxggNLLlx6RD
+RnP9UfLfG+zp1Sv0MQ2hcQTqGfVY93y1VcUiN838PJ4qbHJ2E44q54cAJ457Y0RH
+TGmJFdu9D3jxymy//qbdv/6Q7EteYZ79hnJHIvb5aY5cSpzw5FlB3gmwGc+MLm2+
+ptXE8ohrQVJgxqq7UZeuBhgTtLIIKLMi9lXdHvSAE1laNA7MGCjQQbVuOjiwkUMX
+7JbIlToNXAUflN8A/+ms34ZXJ4F3qQIDAQABAoICAAwULznh7l1F85LYUkDJxB2l
+J0Ufc7WWG21jo0oL1DVFkaf4P7vlTZRj3sXqOFIVmewvUePwc8Etqj6432dasqi2
+fvI1hBJlh1Lty0L4hCCJnGGZ2VsI76UYA2BPM4zKZx1jGz4PKBwWkW8X77b73V7i
+V/IndmBDzYiqblL8ABlTCS1dJ2LvxCdw1Mb5BENfI0h027gAbUJqT2ARBMQmD5Cy
+R3NxEQtuX/USz7LDCOiyquSO4PciGI2WDIRA6/gqn76vTTZs6xTPNZPYuEBHidCi
+c5E7Ekz2CV0BQ3f/vv9qUgU2VwD8wzzMGbjsy2Nd9Ouyv3Ods3+JbKg5FYDhLirT
+OSyRnsL42IpPQ1CBXaSH5aAmGHh03JbR5ZrtTITznXNbTVeXvzRsfC8YiUm0RbHl
+MgJ0MjM9OOXq2fE9X7roQROO2StEpiF7uAb3ZKg+MHWdsbz1Txvj52yV+0uaw8kD
+Qe/GrAs4sPQX++lcuEEzKKZbnMUeRbQfJpQ6XJ9ocp2l5+hYQi9uibFgf+Th61zD
+JfOMvXstqykDtRCrNcw42CVONc0Hq/WkxHwJb1SXrbf5ZH3Z32BnjMjpPrngbaJZ
+jg4aRbpxgMKpI2w+jH5CbQ24vqZ1r0FSYqyLW/lirSaubjC7XB+4U8S6TLRVuPKR
+D/sksijkEd7avSNiUM6bAoIBAQDg9tMudcCumtgpSS3yPhUHcmqZ36pLJgGdqh1Y
+azquTjgrvEtNlmtZLlAne7abGsFG55Q5D1KsLzKeajaZQBXsO2gIt2ndmhj7uHAs
+FM28ZxA6g0xPD9XtWpwUhk4RSEVgBgkD1mWQnY7CY97PiZPtrLlpSlhKeofD3IRI
+qZAjPkBVNF3LX5Zzt0RcTV/66mw61K889BMumADxQ5HElxnJTSP6ZA3jBa7+VUfV
+WhHGIbyBwKrInn3gEAT001devAWpe2tB3h8TWLm6GVDNUPh+XEMzaC6XnD8/9EZ4
+WiznFv2EuQrRBEGzWLLnHKMqqM9zJTY9MxK2QkUbxt+Pq95nAoIBAQC27SsGV8ZA
+WIYSa/A78eHbTCvSfpVpfWZYwsnbm/wt37F5qQJeBRZjLmBfGlVBQd5OkWQqH3Or
+Qr+naDoFh1HtyJN73pKAwcL0RiKQJgu4MMO0Yg50pOWj5SQEDtINrMT7t09o+9Yr
++3tbbUZqspPQJJa4q4NAVErQNfIXyLPFAehpvuFA1mftIIX0v6Ipda+f6vMBcsDE
+ZVR73L3LsUhHTtpVmIRlPge11zp2l/7auWW/csctGisIGTy4z/hlKtbOexfqcEy/
+AImuUgV42MQ9SNs/zZIGBVzj7SAammigKNwkrTTvDDFLDwPiSW3jGxdIyNHIuSFn
+KvUN2lZF7A9vAoIBAQCZEVi/0/82zsUl0lUg7n0AhClkaXtFhqay0H0HHG7hCvfN
+SmEyeZaxJ4ICW3r0LlA0Hn4MFRD7d2HJyBs+SwyfoAw5YhL2ZVN6cxRf6gfSmY3B
+QnU5llzJE03/6SJVQAd0PqcDpW2V6J6qRanlLIcqwMuniJ5qjtSxv+mCR117nW/I
+ickICBImyhwJcdxMrhTWMsDJiIlEushpYMBeMzGodoR0an/IaKWZCA3IbXCKj4nj
+/2Q0/SJIwp2ct/BK1URE/WUNfEVItKYqqe8AxIdP8xcGc2zAW9/tdBg+65yB4HK/
+S+jAL1VE4qVX+3RKInKEtl0hZw7kVFMRg0+6QgchAoIBAFlQvcQwEUWTs4rFFxw9
+wxhvPapeUheaueE24paOt/eyFBah9dzwQPkWSMnGsr/VKHVWOgDzMGf7hFZ55nKS
+mucuGwzNJN0nPkhFAlU9rlU1wiiOBB7h8JowpIAyuE8i/18kJurMo8r/1EBUv6AT
+J2bmpozueXxsa8bzwAvq3BOiFdej8pnU1hOK4L0a4FO7+Y9siVA4tnRuQWzk3+rX
+2cGvd515hZNlUSBxQUNsfWxQP6TVpviXoVEn+WUHbnH/E7jnk0rNoDtWSONLew6B
+UHfX3e1uMxErhypmulki8VjZ+nsR8xmQ63SJlUzkq3nK3XiVTRDji7Ky38nuxFcJ
+ppMCggEAWVp1GKF2iB9smH9WluM+UnMdFVqlMamY7zM5EYJOIOpuY+oMGo9WJhQ4
+1BmYNvO9AExY8yqRENCXuh3FZcrwMoOlpkatIAcRhQMrj6zGDDg6pRAQC2jJfII4
+SOB0FpRoSJG5b5f3mPcl7n48STsAKc7V/OfxDhPr1izQrVSH92oGgxa60yBmHhAX
+uAUXYGWkG/i6AFd8Uh6DHcdPwVNikNAyGFXOUlONOcbFiV1L9T7NjjpGxInpz9uK
+QyfYPVuFZrMME5qWp3s9zF7jNjMnXyYPP6BqZ57VEXvt6JGwcZmZakVeeVrAbt/4
+38JhoT/178W00kVAPWpDXhIUNTA0rQ==
+-----END PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/secure_headers_asn1.bin b/crypto/default/cms/testdata/secure_headers_asn1.bin
new file mode 100644
index 0000000..8f66446
--- /dev/null
+++ b/crypto/default/cms/testdata/secure_headers_asn1.bin
Binary files differ
diff --git a/crypto/default/cms/testdata/secure_headers_sec_header_fields_first.bin b/crypto/default/cms/testdata/secure_headers_sec_header_fields_first.bin
new file mode 100644
index 0000000..ec94a03
--- /dev/null
+++ b/crypto/default/cms/testdata/secure_headers_sec_header_fields_first.bin
Binary files differ
diff --git a/crypto/default/cms/testdata/secure_headers_skip_optional_status.bin b/crypto/default/cms/testdata/secure_headers_skip_optional_status.bin
new file mode 100644
index 0000000..4d8dfd2
--- /dev/null
+++ b/crypto/default/cms/testdata/secure_headers_skip_optional_status.bin
Binary files differ
diff --git a/crypto/default/cms/testdata/signed-apple-ecc.msg b/crypto/default/cms/testdata/signed-apple-ecc.msg
new file mode 100644
index 0000000..9152c98
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-apple-ecc.msg
@@ -0,0 +1,55 @@
+From: Test <user1@foo.bar>
+Content-Type: multipart/signed;
+	boundary="Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC";
+	protocol="application/pkcs7-signature";
+	micalg=sha1
+X-Smtp-Server: 127.0.0.1
+Subject: Signed with ECC
+Message-Id: <2E0E3E9E-BFA8-49F1-B0DA-9F77CD81D268@foo.bar>
+X-Universally-Unique-Identifier: 176E61BD-DBD7-4F8B-ADB6-7AE7E87F3EF8
+Date: Thu, 5 Mar 2015 11:13:11 -0800
+To: user2@foo.bar
+Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\))
+
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC
+Content-Transfer-Encoding: 7bit
+Content-Type: text/plain;
+	charset=us-ascii
+
+Is this message signed?
+
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC
+Content-Disposition: attachment;
+	filename=smime.p7s
+Content-Type: application/pkcs7-signature;
+	name=smime.p7s
+Content-Transfer-Encoding: base64
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIC4DCCAtww
+ggHEoAMCAQICAhI7MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEW
+MBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN
+U21pbWUgVGVzdCBDQTAgFw0xNTAyMjgwMDIyMThaGA8yMjg4MTIxMzAwMjIxOFowHjEcMBoGCSqG
+SIb3DQEJARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC3mZ5kYq5ZA
+9SFWu4USCug1YqPs7W9lLnsMckB2EysfCbPBiEiKn6Nw3wQglJyr0BWaOIet62wO6mvyMKrFQuaj
+gagwgaUwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlm
+aWNhdGUwHQYDVR0OBBYEFJturmeUXWH7c2NxRIzg8uy6Q8ugMB8GA1UdIwQYMBaAFAH6hwo7a1GW
+3Lakpx9Ic99KGP2eMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQw
+DQYJKoZIhvcNAQELBQADggEBAKh6rrijzgAknggRAW2HMe/bAUrpI6vBCMM+C1DnxQxXCtgu1Qps
+d4lMJAFOv7BfVdSSzJwBOvE6SmA85mOn7TD2v1VjkBx5CQM0FOZ6SMpmsrFRkJnnCuJiCW+/8Ua7
+tas6avHqCUvJ7h8+/R1TTNJUP5hVRd9Ofg1yXnSGJCBXp5OYz32M7meFI3J300+Jd+BU93rhD5Ez
+hSbluyVKpCH3Ami9e/qW13ckgdaMF2MxKjnb/ck3yldmthMBpFmTrDJ6Ax/+swSkfjtUaNXV8yZk
+uSSDWIWaQ2Yepa4X1dOaDNzj5oVnfHReZPZLNzN+/ZA7M34qRp80550QuWsNTIcxggIlMIICIQIB
+ATBoMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW
+MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjswCQYFKw4D
+AhoFAKCCAVEwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMTUwMzA1
+MTkxMzExWjAjBgkqhkiG9w0BCQQxFgQU4IyE3V2uqhatrXWX5Kr3T5UMTm8wdwYJKwYBBAGCNxAE
+MWowaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx
+FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7MHkGCyqG
+SIb3DQEJEAILMWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50
+YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EC
+AhI7MAkGByqGSM49AgEERzBFAiA5H6auafdyvrv03b04pqIHjIsXoaGwwZjGpTInX/4j5AIhALad
+Nyyxy6gJrXIeF3GaUJKsMliXHrppjhZp7Piev/+pAAAAAAAA
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC--
diff --git a/crypto/default/cms/testdata/signed-encrypted-thunderbird.msg b/crypto/default/cms/testdata/signed-encrypted-thunderbird.msg
new file mode 100644
index 0000000..adeeca7
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-encrypted-thunderbird.msg
@@ -0,0 +1,95 @@
+From - Thu Feb 12 14:28:37 2015
+X-Mozilla-Status: 0800
+X-Mozilla-Status2: 00000000
+X-Mozilla-Keys:                                                                                 
+FCC: mailbox://user1@localhost/Sent
+X-Identity-Key: id1
+X-Account-Key: account2
+Message-ID: <54DD2915.9080206@foo.bar>
+Date: Thu, 12 Feb 2015 14:28:37 -0800
+From: Laetitia Baudoin <user1@foo.bar>
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.4.0
+MIME-Version: 1.0
+To: user2@foo.bar
+Subject: Signed and encrypted
+Content-Type: application/pkcs7-mime; name="smime.p7m"; smime-type=enveloped-data
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Description: S/MIME Encrypted Message
+
+MIAGCSqGSIb3DQEHA6CAMIACAQAxggMIMIIBgAIBADBoMGIxCzAJBgNVBAYTAlVTMQswCQYD
+VQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBD
+QTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjUwDQYJKoZIhvcNAQEBBQAEggEA4y8AAiMI
+gqP9jtxvoavTuubxQIL+iSsEJCVBP1ekxtvlZa59gdeB8jXXfG9LoPed3mk5CHQoH2LotVJb
+2KTjabtNQCW6GJEUxFJc/GvpotBzjKEcYtnVPS4ROd8FwrpZ8Y/RFJyS+hA7qHRFtaL4psdB
+PxCu4DGfqXvqc41yEGJFqlZ8WADr0MKBJqj9//ft/kqoFEFyP/uEu51nlHVe4a1mKwnL+pqN
+VYGyARS5D2T4lo9w9IB1RPlrF0p7uKbSXNqug95uKvhAxlzRtQCl1QHVKg9pTLDGq9imwF2R
+8r4WNIbXZ0ewK3dDepRdlHkll2BC4oqnp9MHVm56UrB0GDCCAYACAQAwaDBiMQswCQYDVQQG
+EwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNt
+aW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI2MA0GCSqGSIb3DQEBAQUA
+BIIBALXNbVAXRKKTtYXIRq1O6X//VdEKxZ6bCJkIwzcf1JhyIXVALyy0BniW0N0AiapIpfvv
+py81HiAZc0Cciyjr6DP6l2Y6MMWLdZaTeGu+U3o+R4WmHkI3njfmIqmBVHhDmRdOKpU0qRWh
+Vo/K5Y0gkWLDzN5W/fM7fsTr58UxfNSaX+PXFsvk+S8IgP7Qqi3jAE3rMGyjk6ewxrh7djTY
+BU4XoJ3hY6l3KFbzijtbwpzvSMRtArLjFsYa+F5FKO8QYswkCjd//khUoY8YC4Y3YiWRpPRK
+qOoyOTE29IJOvVZv6gUsaQyxENNL9IgGtbQ7z+KnpflvWKjVvepYrtimvVgwgAYJKoZIhvcN
+AQcBMBQGCCqGSIb3DQMHBAjRvDMHjZagZaCABIIMaKdabxwdRcIGo4ag8VZIIrtv/IY9+Hhh
+3FC2sPc1Qyc/RSvIEGFd5SV2NG1z/0XGJfqH2s07ftgtMcGBo+oQTr9TPXiSfQK5pjX+tYYb
+MsceN6+HgJ5GzQdhOwrxeLuXYlRKg+MzJMbEJ4oUkkY/bBF6j5K8CoQpYB85nAdyCb4auLRh
+EJ/gZv9WSr5PF0I1F4Fm1qzU6Eo3jUhmXw20d9cq6m4N06ub5GdEgb8HFo/Mwaiu2u8jeSi+
+cEkDchYbhNMYGPGYCpbcENiULSPZktRM+I4H7HwMUWc5JgsNGSTUHqyfQH46T87oy6v+mPtd
+9iF6OCJMOFwYjfL65pCjxvhKfgFAKtU8epyg0qPMU6wegu+p5zrcmfLKSnku+DwGh94/tGMF
+/y1XmJwfsoGc8YI0oGhuoxHCvH/6dV3hB4xXPREjM+8vDzktMe5/+4gbi/y/O4vVaFKukQIY
+Gfxr2UV+4rsBrgpaufujScVSmR/QYWXblHhgN0xFha9iqL1nJyKb6REpNlZZaH7XKCFsJGu7
+xRUCoawqUCrP7EfHeBlPgnMaXaodFBjoQm9P+0YV5H9l/0fc59OvaDcmLgfLzm/PB6+aixcX
+HhRZffFzRLJm7kpNpeHsnzG0GKUcnD8HcbRPIksCrvL6vtkOlk4KEP8WR8Z49L0TUPW5yQ2x
+v/fmH+1um2DV9ZSfvFWJ65cMxtpjy0l+uA0UDARycXt9As52xIVNu8p0zGasEtebi2qQTZ9E
+A4NOW2XrsjZ0ErlFET4bi3nzwpOaX9w/KJQ5+gaQNULnNzUSkwXdvgV0v4lYUUL3xdL1A/mK
+2Oiz+tUSiLiH/G2tEIpdrOT+bmOv/GSHZGSfqx0O2ibWlG8u4VDLbISCIDpk0oeUHCxMmbTr
+HTH+z5e0UuJd5OFh1qEEXppUASzint5F2Q0XgdNmdBPMqE6I3WileaP13tV39NEFml6rujRf
+sKjbVVPUZC85tnUGJrYKVhUe4OMViccMyyz2VUBTT1aDhn/SKcPPxwn8sZ8RTK3IZLp8FB1y
+7qbKHk4j7dJvwwMVK6K4Zf6xfXOiV8aUivUPTLd8vTCzc9XbW9pQT7uO3mFSp/M8mb0GWL/J
+iacY6dnxzAJ6Rv3J1ZHrJE4x1LXYcUqRx82qEI9PKfhComwga949OXBna2Lo8TmjARFDr/og
+EwRL7++AMKwBcuqjvjChwM3kP52GPdILmsgvL2+/3IeIeXuz30ep4ushJSpK/mtFY+4fyEBc
+xIEI8nAOFGIJAk8qVBZN+rS2Y0JmyTJe0JyaiWRFZswnBP1Upn/PE16i4z5ZWCBPX9Jbavc1
+V2JyQWw2TurEV5F47jLl008XghREyGpLKIG9pAkEBArHE6wUEGMzBsQ+sf4VTGLxP6jjzQQN
+ZdsFu6pHW77ECFztOIWa7Qf0gg7aZ/L/J19n/mI4Y+giKcJxckZ+uzYN7nKLMO3rN3qmmJFb
+EWvA8Vca4y5VAq14iRepDG23PjldhJscBjji/vizzfN1IBHp4woLqkZUj2WC2bdmrD5KYU+4
+yUmcjGHa5hEzcvDYq5JXXSP8U1aY09w36sc3ZFaHvdKGIccNXWodGyGdA8su4xULag+8A8iy
+o7541uOU4bAF0wT41szIvZfuOG8EMnI6OuXdvxswI/Zb7/mxUrLC5G1Px1qqm1JLlrtVoXOf
+NsWM3iVQgS3IZd11Z2QSR0qXd0s2tqYRaPsKKoN3Wgtv1bMnrx7gr6OxpeAlLYtwXQp+EnWY
+he0Aa1whbEYghyhmS3eRkziUCO1UA/1fXUeIM92/WIDrPzMvnUrWFDRXXfwIEYBmVhRtQwB+
+VLyOf32gwmAUDQLdS6TwDwEpgr1tneXJWDWJgB1Hx0Ca5swaFJd/OAaUgQQ+3b9u1gVX8i7p
+RojBZrRyAjbEHbN4ZyCEkXS+2IYOINdKAdjPleOKmeUW5LzZ4F9J+YsvEUKk2pV90jOoddw9
+89gDQrBI9jKcMzP2cvIbxZOdm4eIgURphySeypk4kQaczbTvkqYyoBx+NOr5vHMbV2N7R0MM
+fdzbeel7DqKhVM7BCM85Pb8J6LzD+b3rN/ITfO1x79x/FBAduX8/BPgPCTHPBn2B7JLs2l1s
+FcXg6TfeEqKemqAstDRBOPKo04kCNDBfo+UVveU987IPIiDLAcas7qdZLK1BMBuvr7kYgkB0
+rQnCWsWfjRjOPQ4hpu1JF2QMgxzp+LqIMm5UjFiXHxzVgA/sEs+JLLeJSf9e1MFTzwTh0y6Q
+HIMJzgvRDYQrSB84JU7QM6VH9nst5GIq8AMYb3dtyaBKoqMbdmHz0c4/h85ygDlS+BlvxaR3
+h7pUR/ls0F+0eFNVtCv5l+/JuY6xo/I1i/2kRhvkGb1C4sKAkXoJhSsDFUZYPggW/OYaE7VY
+m2e9WHQLzJqQrRl9UuKpnL6CGPu2jsirURWKdSpnzSdBZ4PsiAILy3D83rRpEHh34egoYnWH
+5E3LbBS/Wy5wxw7fD4H0prnhLgXlZaXmb3/vPD2Z3xSw9KINVVfrZCkPxjuzkLFEI8TKs0D8
+U9/xdwAfSdudQG3NpctTrhey+JNOT/7xzWve03Nl1JXEUQyk0mlPzPUDd7fcDhZsn39hdCQz
+LjURgvbl966N4k2GYNNuEJmyblXD42qs5QIgp2NpX9EX7G2y+wzM+cT2P35Eetiikdi7sBFq
+K+kpGWfM3TggNcVTlNe0RlisEcK5gcI5JV7OYhIc8dUiSY4jvb3cMiK9bxPnQ95H9kz7sqx2
+56yz+zyasszWwn2pIaKNxL9pukM1p6OHpNbhDXq4nzuuZ8rW2rUDAVnpQEZJK+EtR7LOv9cd
+P8HPaEs9Scra5h+9hQVJT4kpIODpExdGkoin8tumgol+NEXckB2alxRVzHJFCt7o2qk3ivwN
+j2eHOYFuhTmKZlVYs6+t5pI7hDbdIpMCI2zkVJ0Is2Vgp5tYMaSwJ+Pw0yOCTn/qWB3FuzM2
+LgNrzit1pC9W8fh9JTmwOn4eKtJY1ha2+0UyaEyyk7ge6oF1lSn6JwmIFIwgoXTa7tZr11SC
+ZjTk2+3zfpamx4fbKiEf0cM6qz86YXe6wlTcITQfK/XqUoqwdTC2y3FeYGLpC3nsaLRGq5Js
+x8CzAJitvP5e9Z2wR3eJ8TE77lm+bJEiHPO4ft7vvFfu4gL0/d4Bdqz1lg+0kgYXYEZoo8Q7
+I34LVMKEg0wq1KApOSggl5bdVdakYojDlMYc//UZaLmGWkVvfM3IVeuLqmCaxtJ9ySOfmKEe
+BJFdkBLSyO7xLa6fRti5AAqJwIj5k2bnmSOfiudvoBP8T19gstUCP1q2Mdkpf/shA6qmyAY+
+a11PpskrnOauitNwdQhT0JT1X5pt0TTf+bb0/kwgHo0R/bW67thP+nqJZ7s0Tkh+t7c8ML9y
+5zWt9DtIr3x4kbELmN6R34ZWnU47DhKdLx91G2OF/kJDxnHtFYG7wws+a3Bup/r+Ahd1rkng
+uUlR5DoYMLNIVKv49xNw5xBO/S1UtFsupvwM74+VuM/GTHjNChAfKVhiTsB+a29A6pFLNtii
+Khv7AG1BbyIvS7Nw1W9UPbuN0akI6wifCA9zy3lGqieGCoZkpWg/2yEDPZtknHh44mNm3Jrf
+UHKCvM+O9xhPRVbkJjKZBjiOT/lrM0DP0ly0vN96nT0CAFhr81aLcSEmgAuVRYC/uFnt6quA
+wROaK8bA/FMETsjdI1E+/T8kkSS2wINas66H5o6v1v0rCF+FBK6S5c5cRauqZSfHHiqwUSGB
+O7xTfU2uJIGWVa5JRpVr4tR2mpxYNbO8otlOEUcNGFGbh//f4oPbu/cQUPVZtZ5LOPYOY4zS
+SRHVnNLwiGLkhOd/+TC1EiDF/iu31VGYSSL3uM9DegfgivBRu4FaZYb33JDu71JReT3jKJ/2
+t/3tMiT/uOa+n/fCybpJehLNp0MgMvtLwBiJWJmtKZMBf+tpwQhR2S6GosHlRzs5O84jgdqZ
+pzZdHrVtFhuegGHDLJ0gzjwxGhwVR2TvOwRoz0Uzeo1o0N23Xw8hODojAFgXmEOSMxtbxHHP
+KQ84sDn+IV0ELj4lSi+oIAYsjsJK9eZ5803HBSzfO8r35BpixAWYqJXBC0dd3hcUt0sH4QPS
+g/T9UUODRWSvgssXNhszHGv4dCTcawf03nlQFbwazMS/qhDy8LE+bEss704rZKn82gd2uvU3
+2mOSGsSYX2/h1Tq8+h4bs18B2IFCBAjRm0yO+YyfIQAAAAAAAAAAAAA=
diff --git a/crypto/default/cms/testdata/signed-office365-owa.msg b/crypto/default/cms/testdata/signed-office365-owa.msg
new file mode 100644
index 0000000..031af23
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-office365-owa.msg
@@ -0,0 +1,124 @@
+Content-Type: multipart/signed; protocol="application/x-pkcs7-signature";
+	micalg=SHA1; boundary="----=_NextPart_000_8231724B.83F20A4B"
+MIME-Version: 1.0
+X-OriginatorOrg: glooey.onmicrosoft.com
+X-MS-Exchange-CrossTenant-originalarrivaltime: 19 Mar 2015 16:14:23.9410
+ (UTC)
+X-MS-Exchange-CrossTenant-fromentityheader: Hosted
+X-MS-Exchange-CrossTenant-id: 12676348-5c4f-465d-a51e-9617de6d9d6e
+X-MS-Exchange-Transport-CrossTenantHeadersStamped: CO2PR11MB0024
+
+------=_NextPart_000_8231724B.83F20A4B
+Content-Type: multipart/mixed;
+	boundary="----=_NextPart_001_8231724B.83F20A4B"
+
+------=_NextPart_001_8231724B.83F20A4B
+Content-Type: multipart/alternative;
+	boundary="----=_NextPart_002_8231724B.83F20A4B"
+
+------=_NextPart_002_8231724B.83F20A4B
+Content-Type: text/plain;
+	charset="utf-8"
+Content-Transfer-Encoding: base64
+
+U2Vjb25kIHRpbWUncyBhIGNoYXJtLg0K
+
+------=_NextPart_002_8231724B.83F20A4B
+Content-Type: text/html;
+	charset="utf-8"
+Content-Transfer-Encoding: base64
+
+PGh0bWw+PGhlYWQ+PG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0
+L2h0bWw7IGNoYXJzZXQ9VVRGLTgiPjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyIgc3R5bGU9ImRpc3Bs
+YXk6bm9uZTsiPjwhLS0gUCB7bWFyZ2luLXRvcDowO21hcmdpbi1ib3R0b206MDt9IC0tPjwvc3R5
+bGU+PC9oZWFkPjxib2R5IGRpcj0ibHRyIj48ZGl2IGlkPSJkaXZ0YWdkZWZhdWx0d3JhcHBlciIg
+c3R5bGU9ImZvbnQtc2l6ZToxMnB0O2NvbG9yOiMwMDAwMDA7YmFja2dyb3VuZC1jb2xvcjojRkZG
+RkZGO2ZvbnQtZmFtaWx5OkNhbGlicmksQXJpYWwsSGVsdmV0aWNhLHNhbnMtc2VyaWY7Ij48cD5T
+ZWNvbmQgdGltZSdzIGEmbmJzcDtjaGFybS48L3A+PC9kaXY+PC9ib2R5PjwvaHRtbD4=
+
+------=_NextPart_002_8231724B.83F20A4B--
+------=_NextPart_001_8231724B.83F20A4B--
+
+------=_NextPart_000_8231724B.83F20A4B
+Content-Type: application/pkcs7-signature; name="smime.p7s"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7s"
+
+MIIQyQYJKoZIhvcNAQcCoIIQujCCELYCAQExCzAJBgUrDgMCGgUAMAsGCSqGSIb3DQEHAaCCDj8w
+ggQ2MIIDHqADAgECAgEBMA0GCSqGSIb3DQEBBQUAMG8xCzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtB
+ZGRUcnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdvcmsxIjAgBgNV
+BAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3QwHhcNMDAwNTMwMTA0ODM4WhcNMjAwNTMwMTA0
+ODM4WjBvMQswCQYDVQQGEwJTRTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRy
+dXN0IEV4dGVybmFsIFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBS
+b290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt/caM+byAAQtOeBOW+0fvGwPzbX6
+I7bO3psRM5ekKUx9k5+9SryT7QMa44/P5W1QWtaXKZRagLBJetsulf24yr83OC0ePpFBrXBWx/BP
+P+gynnTKyJBU6cZfD3idmkA8Dqxhql4Uj56HoWpQ3NeaTq8Fs6ZxlJxxs1BgCscTnTgHhgKo6ahp
+JhiQq0ywTyOrOk+E2N/On+Fpb7vXQtdrROTHre5tQV9yWnEIN7N5ZaRZoJQ39wAvDcKSctrQOHLb
+FKhFxF0qfbe01sTurM0TRLfJK91DACX6YblpalgjEbenM49WdVn1zSnXRrcKK2W200JvFbK4e/vv
+6V1T1TRaJwIDAQABo4HcMIHZMB0GA1UdDgQWBBStvZh6NLQm9/rEJlTvA73gJMtUGjALBgNVHQ8E
+BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zCBmQYDVR0jBIGRMIGOgBStvZh6NLQm9/rEJlTvA73gJMtU
+GqFzpHEwbzELMAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1BZGRU
+cnVzdCBFeHRlcm5hbCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwgQ0Eg
+Um9vdIIBATANBgkqhkiG9w0BAQUFAAOCAQEAsJvghSXC1iPiD5YGkp1BmJzZhHmB2R5bFAcjNmWP
+sNh3u6xBbEdgg1Gw+TI95/z2JhPHgBalv1r8h894eYkhmuJMBwqGNbzy3lHE0pa33H5O7nD9HDnr
+DAJRFC2OvRbgwd9Gdeckrez0QrSFk3AQZ7qdBjVKGNMresxRQqF6Y9Hmu6HFK8I2vhMN5r1jfnl7
+pwkNQKtq3Y+Kw/b2jBpCBVHURfWfp2IhaBUgQzyZ53y9JNipkRdziD9WGzE4GLRxD5rNyA6eji4b
+4YyYg8sfMfFETMYEc0l2YA/H+L0XgGsu6cxMDlqaeQ8gCi7VnmMmHlWSlNiCF1p70LzHj06GBDCC
+BK8wggOXoAMCAQICEQDgI8sVEoNTia1hbnpUZ2shMA0GCSqGSIb3DQEBCwUAMG8xCzAJBgNVBAYT
+AlNFMRQwEgYDVQQKEwtBZGRUcnVzdCBBQjEmMCQGA1UECxMdQWRkVHJ1c3QgRXh0ZXJuYWwgVFRQ
+IE5ldHdvcmsxIjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENBIFJvb3QwHhcNMTQxMjIyMDAw
+MDAwWhcNMjAwNTMwMTA0ODM4WjCBmzELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFu
+Y2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxQTA/
+BgNVBAMTOENPTU9ETyBTSEEtMjU2IENsaWVudCBBdXRoZW50aWNhdGlvbiBhbmQgU2VjdXJlIEVt
+YWlsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAibEN2npTGU5wUh28VqYGJre4
+SeCW51Gr8fBaE0kVo7SMG2C8elFCp3mMpCLfF2FOkdV2IwoU00oCf7YdCYBupQQ92bq7Fv6hh6ku
+Q1JDFnyvMlDIpk9a6QjYz5MlnHuI6DBk5qT4VoD9KiQUMxeZrETlaYujRgZLwjPU6UCfBrCxrJNA
+ubUIkzqcKlOjENs9IGE8VQOO2U52JQIhKfqjfHF2T+7hX4Hp+1SA28N7NVK3hN4iPSwwLTF/Wb1S
+N7AzaS1D6/rWpfGXd2dRjNnuJ+u8pQc4doykqTj/34z1A6xJvsr3c5k6DzKrnJU6Ez0ORjpXdGFQ
+vsZAP8vk4p+iIQIDAQABo4IBFzCCARMwHwYDVR0jBBgwFoAUrb2YejS0Jvf6xCZU7wO94CTLVBow
+HQYDVR0OBBYEFJJha4LhoqCqT+xn8cKj97SAAMHsMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8E
+CDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDARBgNVHSAECjAIMAYGBFUd
+IAAwRAYDVR0fBD0wOzA5oDegNYYzaHR0cDovL2NybC51c2VydHJ1c3QuY29tL0FkZFRydXN0RXh0
+ZXJuYWxDQVJvb3QuY3JsMDUGCCsGAQUFBwEBBCkwJzAlBggrBgEFBQcwAYYZaHR0cDovL29jc3Au
+dXNlcnRydXN0LmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAGypurFXBOquIxdjtzVXzqmthK8AJECOZ
+D8Vmam+x9bS1d14PAmEA330F/hKzpICAAPz7HVtqcgIKQbwFusFY1SbC6tVNhPv+gpjPWBvjImOc
+Uvi7BTarfVil3qs7Y+Xa1XPv7OD7e+Kj//BCI5zKto1NPuRLGAOyqC3U2LtCS5BphRDbpjc06Hvg
+ARClnMo6x59PiDRuimXQGoq7qdzKyjbR9PzCZCk1r9axp3ER0gNDsY8+muyeMlP0dpLKhjQHuSzK
+5hxK2JkNwYbikJL7WkJqIyEQ6WXH9dW7fuqMhSACYurROgcsWcWZM/I4ieW26RZ6H3kU9koQGib6
+fIr7mzCCBU4wggQ2oAMCAQICEQCv2bfqog5Bl9O9Slil7gQKMA0GCSqGSIb3DQEBCwUAMIGbMQsw
+CQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3Jk
+MRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDFBMD8GA1UEAxM4Q09NT0RPIFNIQS0yNTYgQ2xp
+ZW50IEF1dGhlbnRpY2F0aW9uIGFuZCBTZWN1cmUgRW1haWwgQ0EwHhcNMTUwMzA5MDAwMDAwWhcN
+MTYwMzA4MjM1OTU5WjAsMSowKAYJKoZIhvcNAQkBFht0ZXN0QGdsb29leS5vbm1pY3Jvc29mdC5j
+b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCdAvnls5p3hWM8fm4RhTgAQmLjXj4t
+h749GqOVzApWxAfx1PsKTbh4jZ9hFXfTZ+72LP5w6x9cM4/tq9pYP9AuMy+Gyg3h8473uB1Pfisy
+3ex06NGH+5wwvqgrzPLi9Q6lIltuJvTDglXx+59RUZIL1uSai2Q5yrxPYn+zV8apOH1IHTt0ziIw
+6Z6StxDWFCrsEI7a/JT6/sDStiIU4m9dEk+BAsALYBt+wYfFbfLgKbqK7eUnZ182rgRc3sMQmE6y
+Sw9B8hR5MErO7KPCenp2bxYr7p6osWxlPbdjwPOTyOiVWZJ1fIZq5B0dVZkU8oPZeqWc8Bj0eIf6
+otlUqcR9AgMBAAGjggH5MIIB9TAfBgNVHSMEGDAWgBSSYWuC4aKgqk/sZ/HCo/e0gADB7DAdBgNV
+HQ4EFgQUopu+re0WFCkToB5oNLTq+Jk6YYQwDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAw
+IAYDVR0lBBkwFwYIKwYBBQUHAwQGCysGAQQBsjEBAwUCMBEGCWCGSAGG+EIBAQQEAwIFIDBGBgNV
+HSAEPzA9MDsGDCsGAQQBsjEBAgEBATArMCkGCCsGAQUFBwIBFh1odHRwczovL3NlY3VyZS5jb21v
+ZG8ubmV0L0NQUzBdBgNVHR8EVjBUMFKgUKBOhkxodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9DT01P
+RE9TSEEyNTZDbGllbnRBdXRoZW50aWNhdGlvbmFuZFNlY3VyZUVtYWlsQ0EuY3JsMIGQBggrBgEF
+BQcBAQSBgzCBgDBYBggrBgEFBQcwAoZMaHR0cDovL2NydC5jb21vZG9jYS5jb20vQ09NT0RPU0hB
+MjU2Q2xpZW50QXV0aGVudGljYXRpb25hbmRTZWN1cmVFbWFpbENBLmNydDAkBggrBgEFBQcwAYYY
+aHR0cDovL29jc3AuY29tb2RvY2EuY29tMCYGA1UdEQQfMB2BG3Rlc3RAZ2xvb2V5Lm9ubWljcm9z
+b2Z0LmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAZ0Zro8Stt+qIKjF6F7WZ/zmlOPI7y8fxOyXvZs9A
+Eh810/GHWFDC97RG6h6xc2zpk6Vro20/10wbCKxevGa8F1ZVPI58VroPxHaQ0f5B7Si+B2xvU9JL
+kFCmvtHTQqdtb+hA1WD6TqlqE9xAzTmKyc5E2Mo1pBU3qnwZvxtrV0UuaGu+ThqstQ5qjaefbLrv
+DWqqMwZ1tR7wSc+YNW7wy4qK2zIIcy+Nq5dE8z2sJN/OKPWAz0AwKGr9mO1c48YcxIJgVQApBV0e
+sjf8YzS+1zPRQJY3P3qFbFeba9XzgDOe0+MrC7AHh+DHcd7+xrZ7Z5fywpLfSNbbX5rTygh1eTGC
+AlIwggJOAgEBMIGxMIGbMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVy
+MRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01PRE8gQ0EgTGltaXRlZDFBMD8GA1UEAxM4
+Q09NT0RPIFNIQS0yNTYgQ2xpZW50IEF1dGhlbnRpY2F0aW9uIGFuZCBTZWN1cmUgRW1haWwgQ0EC
+EQCv2bfqog5Bl9O9Slil7gQKMAkGBSsOAwIaBQCgdzAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB
+MBgGCSqGSIb3DQEJDzELMAkwBwYFKw4DAhowHAYJKoZIhvcNAQkFMQ8XDTE1MDMxOTE2MTQyMlow
+IwYJKoZIhvcNAQkEMRYEFLT58XOVd+YVGAWSdX67NTwdw2ddMA0GCSqGSIb3DQEBAQUABIIBACis
+Hx+eCFcWmIuqMAf925QbbEGGg99VowWN+4+10PWbOZ4drQifH9G9bordq4voZ92jLpyiMGAuX4Hk
+stl7/BIRupFvD96zEZOYoWsKVScNYXPd80PkY9btUnhVzxGuAx5DRI7rsm+lJ/C1D6qk/B965HPa
+qKCw3inAOvQGnWzzcjgtk6zeevU1VQlKNH4yQsD617IL+8yYeMEM9GQla8+4FXIe+2Ky1hPDuruc
+G84RGy4xKTwLY79N+4gmq0+z8HcRElPzoLYgVwc9ZpdKEIGLGHpaaUUQHRQxfOHCZHVAuQeNhSMk
+GhuUz79oh+igHxcypMlmRBm1UsUF59v4QWY=
+
+------=_NextPart_000_8231724B.83F20A4B--
\ No newline at end of file
diff --git a/crypto/default/cms/testdata/signed-openssl-detached.msg b/crypto/default/cms/testdata/signed-openssl-detached.msg
new file mode 100644
index 0000000..4a3076c
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-openssl-detached.msg
@@ -0,0 +1,73 @@
+MIME-Version: 1.0
+Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----DDD1F9689000B2862D91BC8B50837CE2"
+
+This is an S/MIME signed message
+
+------DDD1F9689000B2862D91BC8B50837CE2
+Content-Type: text/plain
+
+This is a message.
+There are many like it, but this one is mine.
+
+------DDD1F9689000B2862D91BC8B50837CE2
+Content-Type: application/x-pkcs7-signature; name="smime.p7s"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7s"
+
+MIIJ+gYJKoZIhvcNAQcCoIIJ6zCCCecCAQExDzANBglghkgBZQMEAgEFADALBgkq
+hkiG9w0BBwGgggdEMIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3DQEB
+CwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRh
+aW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUg
+VGVzdCBDQTAgFw0xNTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjELMAkG
+A1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYw
+FAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMIIB
+IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9AiYIFq
+rOPrQTuOHS3siZJ+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRLH1bo
+Mdj0GYSTwk/Jv49xsUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqWXs4B
+AhIGE2Hp3fhJjy384WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX3qzv
+31EXDfBm4sfcbZwZAQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9etO+
+ppjouHY5dVK6Enkx+dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8URwID
+AQABo1AwTjAdBgNVHQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0jBBgw
+FoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B
+AQsFAAOCAQEAGaVllmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdfvVvV
+JxURELclnw7ftoIhdm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0DgDKgJ
+mPF+roauluLXHbcNXe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHqXYOY
+CRZ8KABwVmXbucqD1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK23SFC
+kpy52f4PmFEP/q6GJzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI84xs
+VYCL3eEmgjgbnPghhdWuyA3tc/Wsy6ZNkzCCA6MwggKLoAMCAQICAhI1MA0GCSqG
+SIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwN
+TW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN
+U21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1Mlow
+ADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzp
+emUy4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQf
+DjbYNB/L37tANWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn
+7fFUvLFbA7+9t0mxEYwiYkx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304
+iJhsgtY8HwnA8FV3NoI87Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1
+rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRrmk+Q
+W7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NM
+IEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2xe4T
+PXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQD
+AgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11
+c2VyMUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6g
+wYVu2BYXUjrjI5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5
+OJWHEoByWNr+XkS0mUeRED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI9
+9JWnZfLFcgGmqUosksfcxcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx
+6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWnmlzO
+eYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukjKl8D
+MYICejCCAnYCAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNV
+BAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNV
+BAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUAoIHkMBgGCSqGSIb3
+DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMDE4MjUyOVow
+LwYJKoZIhvcNAQkEMSIEIDSJELo+jcHVEWLuncY0yKC1V0ea6VxZLARNyFAT1s7R
+MHkGCSqGSIb3DQEJDzFsMGowCwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQBFjALBglg
+hkgBZQMEAQIwCgYIKoZIhvcNAwcwDgYIKoZIhvcNAwICAgCAMA0GCCqGSIb3DQMC
+AgFAMAcGBSsOAwIHMA0GCCqGSIb3DQMCAgEoMA0GCSqGSIb3DQEBAQUABIIBAG1q
+k8Dp+u6wGtF2CHl1+KwnIYB0r+fe9xFSCzrJkVmqYn4ArQfP0MXsp0mVDIlrUd6o
+e3BvXKhbH49dqOIUS+/p05bV+/ncK/p6zS8NtvV6yNw9rKKuqFJj/n1WxUS85Ys8
+SbH3gVCsa6BtYgdb58kWWkHVq1JbfPwmMcC3Azm8hMLemqVMCDk/Ni4uoW8uGual
+hdpNrrJpSV+2drRRvZ7RBmm+lAsrQKlSpMq8d13AgYLTBKW3GNVWITOkaTAsUNCf
+YXbVOb8wDe5sMBS1/j2tHC6jkUnZ23e8XGT962EaWuTXqwc1Mv+kpDlzb70FJcdC
+69k6LSNMisrJQvgN0uo=
+
+------DDD1F9689000B2862D91BC8B50837CE2--
+
diff --git a/crypto/default/cms/testdata/signed-openssl-ecdsa.msg b/crypto/default/cms/testdata/signed-openssl-ecdsa.msg
new file mode 100644
index 0000000..4022d1e
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-openssl-ecdsa.msg
@@ -0,0 +1,29 @@
+To: user2@foo.bar
+From: user1@foo.bar
+Subject: An ECDSA signed message
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIIDuQYJKoZIhvcNAQcCoIIDqjCCA6YCAQExDzANBglghkgBZQMEAgEFADBuBgkq
+hkiG9w0BBwGgYQRfQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNClRoaXMgaXMg
+YSBtZXNzYWdlLg0KVGhlcmUgYXJlIG1hbnkgbGlrZSBpdCwgYnV0IHRoaXMgb25l
+IGlzIG1pbmUuDQqgggGRMIIBjTCCATOgAwIBAgIUa+8LZ3I2YsVOmR3jZc3fhOKk
+As0wCgYIKoZIzj0EAwIwHjEcMBoGCSqGSIb3DQEJARYNdXNlcjFAZm9vLmJhcjAg
+Fw0yNTAyMDQxODEwNTFaGA8yMTI1MDExMTE4MTA1MVowHjEcMBoGCSqGSIb3DQEJ
+ARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEohl8KB
+SAoHYneQBw2mNtMHlwQnhOJ3SkgxIRmtbjZPv4oAhnAdt+DPfgnDzxaYcvVIfisy
+9TlBJWunVTLCER2jTTBLMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcD
+BAYIKwYBBQUHAwIwHQYDVR0OBBYEFKdS4cMK47gNgHVz3U1LbGaSrgDrMAoGCCqG
+SM49BAMCA0gAMEUCIG2Y1uTARUXlwQDv5R6qkJOrqRXf3uB17vSUPA2uHZzdAiEA
+kFBdERbZJydkGzQ48CaUErzidj2T6MOBpSNgXoOhAjExggGJMIIBhQIBATA2MB4x
+HDAaBgkqhkiG9w0BCQEWDXVzZXIxQGZvby5iYXICFGvvC2dyNmLFTpkd42XN34Ti
+pALNMA0GCWCGSAFlAwQCAQUAoIHkMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEw
+HAYJKoZIhvcNAQkFMQ8XDTI1MDIwNTE0NTQxMVowLwYJKoZIhvcNAQkEMSIEIDSJ
+ELo+jcHVEWLuncY0yKC1V0ea6VxZLARNyFAT1s7RMHkGCSqGSIb3DQEJDzFsMGow
+CwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQBFjALBglghkgBZQMEAQIwCgYIKoZIhvcN
+AwcwDgYIKoZIhvcNAwICAgCAMA0GCCqGSIb3DQMCAgFAMAcGBSsOAwIHMA0GCCqG
+SIb3DQMCAgEoMAoGCCqGSM49BAMCBEYwRAIgDELOU/9nLirfnYfanDwtY0aJTWjf
++cwj3a8b2Wz0LNECIG1rMYy+qKR3qlQMTsnuvcYygeOhYlhUDrjNXwEMkd1Z
+
diff --git a/crypto/default/cms/testdata/signed-openssl-opaque.msg b/crypto/default/cms/testdata/signed-openssl-opaque.msg
new file mode 100644
index 0000000..10e469d
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-openssl-opaque.msg
@@ -0,0 +1,62 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIIKXQYJKoZIhvcNAQcCoIIKTjCCCkoCAQExDzANBglghkgBZQMEAgEFADBuBgkq
+hkiG9w0BBwGgYQRfQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNClRoaXMgaXMg
+YSBtZXNzYWdlLg0KVGhlcmUgYXJlIG1hbnkgbGlrZSBpdCwgYnV0IHRoaXMgb25l
+IGlzIG1pbmUuDQqgggdEMIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3
+DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91
+bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21p
+bWUgVGVzdCBDQTAgFw0xNTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjEL
+MAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3
+MRYwFAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENB
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9Ai
+YIFqrOPrQTuOHS3siZJ+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRL
+H1boMdj0GYSTwk/Jv49xsUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqW
+Xs4BAhIGE2Hp3fhJjy384WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX
+3qzv31EXDfBm4sfcbZwZAQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9
+etO+ppjouHY5dVK6Enkx+dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8U
+RwIDAQABo1AwTjAdBgNVHQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0j
+BBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG
+9w0BAQsFAAOCAQEAGaVllmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdf
+vVvVJxURELclnw7ftoIhdm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0Dg
+DKgJmPF+roauluLXHbcNXe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHq
+XYOYCRZ8KABwVmXbucqD1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK2
+3SFCkpy52f4PmFEP/q6GJzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI
+84xsVYCL3eEmgjgbnPghhdWuyA3tc/Wsy6ZNkzCCA6MwggKLoAMCAQICAhI1MA0G
+CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UE
+BwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UE
+AwwNU21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1
+MlowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2
+xBzpemUy4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoK
+LTQfDjbYNB/L37tANWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQ
+V6vn7fFUvLFbA7+9t0mxEYwiYkx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUK
+Z304iJhsgtY8HwnA8FV3NoI87Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3n
+L7C1rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRr
+mk+QW7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu
+U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2
+xe4TPXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0P
+BAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAP
+gQ11c2VyMUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert4
+8r6gwYVu2BYXUjrjI5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EB
+kRr5OJWHEoByWNr+XkS0mUeRED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg
+6bI99JWnZfLFcgGmqUosksfcxcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyD
+BNqx6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWn
+mlzOeYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukj
+Kl8DMYICejCCAnYCAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAU
+BgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAU
+BgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUAoIHkMBgGCSqG
+SIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMDE4MjQ0
+NVowLwYJKoZIhvcNAQkEMSIEIDSJELo+jcHVEWLuncY0yKC1V0ea6VxZLARNyFAT
+1s7RMHkGCSqGSIb3DQEJDzFsMGowCwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQBFjAL
+BglghkgBZQMEAQIwCgYIKoZIhvcNAwcwDgYIKoZIhvcNAwICAgCAMA0GCCqGSIb3
+DQMCAgFAMAcGBSsOAwIHMA0GCCqGSIb3DQMCAgEoMA0GCSqGSIb3DQEBAQUABIIB
+ADI+8+4mqgv0zPwUenNTbav+2n7oT2wy/5a0Zpp5Dmn08H6Azf9zV91e3ihqM90J
+hb2KVapTH4+T+zOsM2oP3by1oUrUM3/RXPtjoW5yo/K37zSo2Gh2f9C11jFsREAv
+8pk6lpwIxBSWBI4Ivhju3xKJ/ZJCpre5ofJCzqfSyG8Jfd+LJBeXhWqCw6jMsDdq
+PIK3n+ujeIbJam8AGSrCQ7MQqZZl1mU9zed3wihhgopqo5hntaYArG5atOsUYN0M
+I1XEAu+NtmMegY0J3Rxm6B2H79AvrfGwY3+tz5MNUxyFGVGOM4kLJ0vfClH+UcaK
+SxT8MPwB7JqNg7DGN9NDRWU=
+
diff --git a/crypto/default/cms/testdata/signed-openssl-pss.msg b/crypto/default/cms/testdata/signed-openssl-pss.msg
new file mode 100644
index 0000000..9a7ff08
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-openssl-pss.msg
@@ -0,0 +1,52 @@
+MIME-Version: 1.0
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg="sha-256"; boundary="----82DD06720081D00BBE9FFA7C74EEA6DF"
+
+This is an S/MIME signed message
+
+------82DD06720081D00BBE9FFA7C74EEA6DF
+This is a message. There are many like it, but this one is mine.
+
+------82DD06720081D00BBE9FFA7C74EEA6DF
+Content-Type: application/pkcs7-signature; name="smime.p7s"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7s"
+
+MIIGoAYJKoZIhvcNAQcCoIIGkTCCBo0CAQExDTALBglghkgBZQMEAgEwCwYJKoZI
+hvcNAQcBoIIDpzCCA6MwggKLoAMCAQICAhI1MA0GCSqGSIb3DQEBCwUAMGIxCzAJ
+BgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW
+MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQTAg
+Fw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1MlowADCCASIwDQYJKoZIhvcN
+AQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzpemUy4teY0brqNg1dFRnL
+PhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQfDjbYNB/L37tANWJO+LRb
+i6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn7fFUvLFbA7+9t0mxEYwi
+Ykx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304iJhsgtY8HwnA8FV3NoI8
+7Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1rCPvLJqRzGCyQQmbpLQ8
+NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRrmk+QW7MCAwEAAaOBwjCBvzAJ
+BgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0
+aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2xe4TPXN8E50wHwYDVR0jBBgw
+FoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQG
+CCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11c2VyMUBmb28uYmFyMA0G
+CSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6gwYVu2BYXUjrjI5eNYNSX
+2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5OJWHEoByWNr+XkS0mUeR
+ED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI99JWnZfLFcgGmqUosksfc
+xcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx6rtf4hfQ+057TSRepOgC
+Q/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWnmlzOeYB5itIY6pcImNiO8GVe
+dfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukjKl8DMYICvzCCArsCAQEwaDBi
+MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZp
+ZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3Qg
+Q0ECAhI1MAsGCWCGSAFlAwQCAaCB+jAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB
+MBwGCSqGSIb3DQEJBTEPFw0xNTAzMTMyMzI2MzhaMC8GCSqGSIb3DQEJBDEiBCBG
+iE9wB6Onej4OhZJT/RkhZtlDY89mRxIrg3O51sSIujCBjgYJKoZIhvcNAQkPMYGA
+MH4wCwYJYIZIAWUDBAEqMAgGBiqFAwICCTAIBgYqhQMCAhUwCwYJYIZIAWUDBAEW
+MAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZI
+hvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwPgYJKoZIhvcNAQEKMDGg
+DTALBglghkgBZQMEAgGhGjAYBgkqhkiG9w0BAQgwCwYJYIZIAWUDBAIBogQCAgDe
+BIIBAL/u6USKX2/x/lEvLjzQacTvIVBumfk3l8kz/gACG1wgi8F8hxyDS9A4VX7+
+biL+ppwFz4TOrYzCNOTObmEO9Lc74oeFrIy/RMJkoXjoIPwXu0Ugd2sMUvtIwlDj
+zMURhXro00ihKo7A62CUTNc9IYKLY57om1t/mg0zIs2/okEF+HxLWP8CnT6V3lJn
+CcVj7ZEpx6ElQajOHw8qRZ8pGNOlckuby1pOf+GL0ZQYY+S5hRgZYOL9YJGXDchI
+3VikLqXbsYQAXZ78zxyJ37VKFhazaDODYDG0C7xJa/djvXNNcHh9SSA16Wai1w8C
+AeWak+m9nWOltzxUJ7S4hGPzee8=
+
+------82DD06720081D00BBE9FFA7C74EEA6DF--
+
diff --git a/crypto/default/cms/testdata/signed-openssl-sha256.msg b/crypto/default/cms/testdata/signed-openssl-sha256.msg
new file mode 100644
index 0000000..bac6938
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-openssl-sha256.msg
@@ -0,0 +1,51 @@
+MIME-Version: 1.0
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg="sha-256"; boundary="----0FA69BB99AAC88C2CB154D35042F71AD"
+
+This is an S/MIME signed message
+
+------0FA69BB99AAC88C2CB154D35042F71AD
+This is a message. There are many like it, but this one is mine.
+
+------0FA69BB99AAC88C2CB154D35042F71AD
+Content-Type: application/pkcs7-signature; name="smime.p7s"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7s"
+
+MIIGbwYJKoZIhvcNAQcCoIIGYDCCBlwCAQExDTALBglghkgBZQMEAgEwCwYJKoZI
+hvcNAQcBoIIDpzCCA6MwggKLoAMCAQICAhI1MA0GCSqGSIb3DQEBCwUAMGIxCzAJ
+BgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW
+MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQTAg
+Fw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1MlowADCCASIwDQYJKoZIhvcN
+AQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzpemUy4teY0brqNg1dFRnL
+PhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQfDjbYNB/L37tANWJO+LRb
+i6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn7fFUvLFbA7+9t0mxEYwi
+Ykx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304iJhsgtY8HwnA8FV3NoI8
+7Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1rCPvLJqRzGCyQQmbpLQ8
+NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRrmk+QW7MCAwEAAaOBwjCBvzAJ
+BgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0
+aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2xe4TPXN8E50wHwYDVR0jBBgw
+FoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQDAgXgMB0GA1UdJQQWMBQG
+CCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11c2VyMUBmb28uYmFyMA0G
+CSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6gwYVu2BYXUjrjI5eNYNSX
+2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5OJWHEoByWNr+XkS0mUeR
+ED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI99JWnZfLFcgGmqUosksfc
+xcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx6rtf4hfQ+057TSRepOgC
+Q/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWnmlzOeYB5itIY6pcImNiO8GVe
+dfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukjKl8DMYICjjCCAooCAQEwaDBi
+MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZp
+ZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3Qg
+Q0ECAhI1MAsGCWCGSAFlAwQCAaCB+jAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB
+MBwGCSqGSIb3DQEJBTEPFw0xNTAzMTcxNzU3NTdaMC8GCSqGSIb3DQEJBDEiBCBG
+iE9wB6Onej4OhZJT/RkhZtlDY89mRxIrg3O51sSIujCBjgYJKoZIhvcNAQkPMYGA
+MH4wCwYJYIZIAWUDBAEqMAgGBiqFAwICCTAIBgYqhQMCAhUwCwYJYIZIAWUDBAEW
+MAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZI
+hvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAE
+ggEAssg83jzMPhOkIE08U0rRtRjxF8zTBrkxiEYo1Fqj3v7UMbNYAkGRAlZR9juP
+CuSJQIOkidADaTdvu4ghpN1P0ClPPYyti+5mI1+WkGUpvOLJZpZOTESPd5wQ2/r6
+qz3ciLRxVNaaygJ8Twhu3mq1jzzaP5Zprv7pBM44tvtGo2ehVo/yU5vSSeyNPost
+fxcst2ODNMzYtlxxMMuPgRtOYDRpMZurWwm6sunhxDGgqsqLfE/w4DR11Plj1+D6
+5HUF9accHsId+TOCS72dCYZgO1CHWOjecl8cW5ccs6f3aqeqy9QP6TnGYzmk7ZHR
+k0Xmh9pugoRbYM4Ja1D/DAnmEg==
+
+------0FA69BB99AAC88C2CB154D35042F71AD--
+
diff --git a/crypto/default/cms/testdata/signed-outlook-mac-2011.msg b/crypto/default/cms/testdata/signed-outlook-mac-2011.msg
new file mode 100644
index 0000000..a6c86c8
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-outlook-mac-2011.msg
@@ -0,0 +1,97 @@
+User-Agent: Microsoft-MacOutlook/14.3.8.130913
+Date: Wed, 18 Feb 2015 23:19:02 -0800
+Subject: Testing
+From: Laetitia <user1@foo.bar>
+To: <user2@foo.bar>
+Message-ID: <D10ACE66.1%user1@foo.bar>
+Thread-Topic: Testing
+Mime-version: 1.0
+Content-type: multipart/signed;
+	protocol="application/pkcs7-signature";
+	micalg=sha256;
+	boundary="B_3507146347_316634"
+
+> This message is in MIME format. Since your mail reader does not understand
+this format, some or all of this message may not be legible.
+
+--B_3507146347_316634
+Content-type: multipart/alternative;
+	boundary="B_3507146342_262900"
+
+
+--B_3507146342_262900
+Content-type: text/plain;
+	charset="US-ASCII"
+Content-transfer-encoding: 7bit
+
+With signature
+
+
+
+--B_3507146342_262900
+Content-type: text/html;
+	charset="US-ASCII"
+Content-transfer-encoding: quoted-printable
+
+<html><head></head><body style=3D"word-wrap: break-word; -webkit-nbsp-mode: s=
+pace; -webkit-line-break: after-white-space; color: rgb(0, 0, 0); font-size:=
+ 14px; font-family: Calibri, sans-serif;"><div>With signature</div></body></=
+html>
+
+--B_3507146342_262900--
+
+--B_3507146347_316634
+Content-type: application/pkcs7-signature; name="smime.p7s"
+Content-transfer-encoding: base64
+Content-disposition: attachment;
+	filename="smime.p7s"
+
+MIIJfgYJKoZIhvcNAQcCoIIJbzCCCWsCAQExDzANBglghkgBZQMEAgEFADALBgkqhkiG9w0B
+BwGgggdEMIIDozCCAougAwIBAgICEjUwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1TbWltZSBU
+ZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAwMjg1MloYDzIyODgx
+MTI1MDAyODUyWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4169/JKdJtFj
+cLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHggHzQVHEnC8gpn5cHRyKd5E3vMcX+degotNB8O
+Ntg0H8vfu0A1Yk74tFuLqKk0Oxk/W5jwT9d1RUckryFtMHI05PaEa+FydVBXq+ft8VS8sVsD
+v723SbERjCJiTHQ6jbjW8F+jE7icpA9pyvblgo4u1OegPFloVQpnfTiImGyC1jwfCcDwVXc2
+gjztDWFJeah5wuAEA/9WbbGbbs0lS6uruAbnMmnRvecvsLWsI+8smpHMYLJBCZuktDw02Lfb
+xIAC525MF2jYdU60/bPSZ9CoPrLF/hRaBGuaT5BbswIDAQABo4HCMIG/MAkGA1UdEwQCMAAw
+LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQW
+BBTh4y7jea7wZBgt7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9
+njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMBgGA1UdEQQR
+MA+BDXVzZXIxQGZvby5iYXIwDQYJKoZIhvcNAQELBQADggEBAG/2ISca34xc4ngSu3jyvqDB
+hW7YFhdSOuMjl41g1JfayMdV+5iS/7GssN4qjEiig7cWM82YfTMM2ShPgQGRGvk4lYcSgHJY
+2v5eRLSZR5EQPVIWlKMM0VfdAPCRzd9pJpAqt+DGJrYOO9o82aDpsj30ladl8sVyAaapSiyS
+x9zFylbW5Evp8pE9Ad9Bb8vk9u1hlC0aJQVRU3q4XIME2rHqu1/iF9D7TntNJF6k6AJD/CGO
+45vxnFFguMY5RO3xofEGQsvy0/N2df+phaeaXM55gHmK0hjqlwiY2I7wZV51+/X8szHf6bRG
+RWRoeGiqD9/HKYqtGAK5hkGa6SMqXwMwggOZMIICgaADAgECAgkAo4dLLJzooZAwDQYJKoZI
+hvcNAQELBQAwYjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFp
+biBWaWV3MRYwFAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENB
+MCAXDTE1MDIwNjE4MDQyMVoYDzIyODgxMTIxMTgwNDIxWjBiMQswCQYDVQQGEwJVUzELMAkG
+A1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3Qg
+Q0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
+AoIBAQDPHpKAE48p1LJ0SToD0CJggWqs4+tBO44dLeyJkn4q5p+f/bujh9FPMDeS9mV2xT1F
+NEqDQnTnOZZk5tRuJEsfVugx2PQZhJPCT8m/j3GxTGEmNKr905Qke2mj+Q0CoJAGPq1mn6Xr
+PPnIfUwZepZezgECEgYTYend+EmPLfzhalLNUyrz3KwvNVb1zB0eTT9ucin+qKABAoDx4o7p
+lxferO/fURcN8Gbix9xtnBkBAIVEmi8ePO8f1MrG/Yovh1V0Ycp0D7vdMa/rNvVom716076m
+mOi4djl1UroSeTH52TzgJNoAE6oXMZPSrqv5zeKxFSK8wTS35oo3IYyEjxRHAgMBAAGjUDBO
+MB0GA1UdDgQWBBQB+ocKO2tRlty2pKcfSHPfShj9njAfBgNVHSMEGDAWgBQB+ocKO2tRlty2
+pKcfSHPfShj9njAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAZpWWWakp8Euj9
+FE7HjkXfe09eIlOyXxUL53BFlWIr3VO9J1+9W9UnFREQtyWfDt+2giF2bmlFqsRgJb35a1fM
+qsCAiaLR8+Fn0AYRQX74r/QnQOAMqAmY8X6uhq6W4tcdtw1d7wZsCl4GVwYwGzcH9+M7kXFC
+cz67WOWJY4TCsxvFUepdg5gJFnwoAHBWZdu5yoPXGupUm9JLwsC/FcOHHx6vpvTQrHrDy7h8
+YOymbHdKsrbdIUKSnLnZ/g+YUQ/+roYnO8HDjcODsqIgyCcdMxOOEagEcq1qh0pGewYFnia4
+0QjzjGxVgIvd4SaCOBuc+CGF1a7IDe1z9azLpk2TMYIB/jCCAfoCAQEwaDBiMQswCQYDVQQG
+EwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNt
+aW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUA
+oGkwLwYJKoZIhvcNAQkEMSIEIDP6/P895N5spXIY/Gxr79A4lWq03XNGwd7r7MhqIvuGMBgG
+CSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxOTA3MTkwMlow
+DQYJKoZIhvcNAQEBBQAEggEAtQ0Vg7RNn5bvrO0CSWl2zsY98LQAJtGRmNUT5dbtQBC3qP4m
+ld9asxu7afr1mpH+CBpZZ28N/+5Ab8+h2vthkd7Gt2oInfRJ5B9ABeVkM1gbVjADTt3tPO+0
+3rFR2FpqLipg3OiiRDKog4EWtpr/gSaucAUylyf9V2SDD1u6tubzcuYLvxTudy1vSkZ2G76E
+BZWOtZRRW8KFlUJwyOFuV1tCp7Hf3T0mplNivM2nEww2BkiTlmRxPYi5Q783n+lLGA9r8P09
+Ozyulu9DeHciQwVCraGRYqhfLxZ2lf939kbXxdQiAmViRf8k0u0bbJWtLD5TDDczGVxbp9gx
+Qx7DHQ==
+
+--B_3507146347_316634--
+
diff --git a/crypto/default/cms/testdata/signed-thunderbird.msg b/crypto/default/cms/testdata/signed-thunderbird.msg
new file mode 100644
index 0000000..e68a16a
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-thunderbird.msg
@@ -0,0 +1,69 @@
+From - Thu Feb 12 14:25:09 2015
+X-Mozilla-Status: 0800
+X-Mozilla-Status2: 00000000
+X-Mozilla-Keys:                                                                                 
+FCC: mailbox://user1@localhost/Sent
+X-Identity-Key: id1
+X-Account-Key: account2
+Message-ID: <54DD2845.5040101@foo.bar>
+Date: Thu, 12 Feb 2015 14:25:09 -0800
+From: Laetitia Baudoin <user1@foo.bar>
+X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0
+User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.4.0
+MIME-Version: 1.0
+To: user2@foo.bar
+Subject: Testing
+Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha1; boundary="------------ms040407050600080904030003"
+
+This is a cryptographically signed message in MIME format.
+
+--------------ms040407050600080904030003
+Content-Type: text/plain; charset=utf-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+
+This is my message.
+There are many like it but this one is mine.
+
+
+--------------ms040407050600080904030003
+Content-Type: application/pkcs7-signature; name="smime.p7s"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7s"
+Content-Description: S/MIME Cryptographic Signature
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIDpzCC
+A6MwggKLoAMCAQICAhI1MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQI
+DAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEW
+MBQGA1UEAwwNU21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1
+MlowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzpemUy
+4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQfDjbYNB/L37tA
+NWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn7fFUvLFbA7+9t0mxEYwi
+Ykx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304iJhsgtY8HwnA8FV3NoI87Q1hSXmo
+ecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo
+2HVOtP2z0mfQqD6yxf4UWgRrmk+QW7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG
++EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu
+8GQYLez2xe4TPXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0P
+BAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11c2Vy
+MUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6gwYVu2BYXUjrj
+I5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5OJWHEoByWNr+XkS0mUeR
+ED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI99JWnZfLFcgGmqUosksfcxcpW1uRL
+6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjG
+OUTt8aHxBkLL8tPzdnX/qYWnmlzOeYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/f
+xymKrRgCuYZBmukjKl8DMYIDUjCCA04CAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwC
+Q0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAU
+BgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MAkGBSsOAwIaBQCgggG/MBgGCSqGSIb3DQEJAzEL
+BgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMjIyMjUwOVowIwYJKoZIhvcNAQkE
+MRYEFPt0n9vI8+XW0R8C4icgSPkK0ANeMGwGCSqGSIb3DQEJDzFfMF0wCwYJYIZIAWUDBAEq
+MAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwIC
+AUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwdwYJKwYBBAGCNxAEMWowaDBiMQswCQYDVQQG
+EwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNt
+aW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MHkGCyqGSIb3DQEJEAIL
+MWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZp
+ZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1
+MA0GCSqGSIb3DQEBAQUABIIBAAMzzvpz9EBUvXj1X2pq+a6sVG4J1SvwaaRxr7Cn+syeBI6L
+7CmjKojuzGvae8Or7rnS6GMpcd3m8kOHrlndmu7KP5nbuVX7+CTrunp9Fgr4ymUNUzc69hoS
+2gQq29Ow7EMqpleOSeznzh/lr4iXsmow4j8/Cc+2aZfwfsW0sqe3Yvi644z4DsBW94jErjwo
+uHHo+l5xCYB1Uu02uwD9Zau3Hkjlr5rqnH2IgASkE+ghK7nxsvMWKp8342ZP+mxOUAy6wPCe
+NiAj9UzRwCz+Be877Wd9nProyJ9oKlMUkfqLnLaYDEdVtcTeextGwxulM22yYFOKGxCdC/SF
+QHCfeV4AAAAAAAA=
+--------------ms040407050600080904030003--
diff --git a/crypto/default/cms/testdata/signed-type-mismatch.msg b/crypto/default/cms/testdata/signed-type-mismatch.msg
new file mode 100644
index 0000000..f32e18f
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-type-mismatch.msg
@@ -0,0 +1,55 @@
+From: Test <user1@foo.bar>
+Content-Type: multipart/signed;
+	boundary="Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC";
+	protocol="application/pkcs7-signature";
+	micalg=sha1
+X-Smtp-Server: 127.0.0.1
+Subject: Signed with ECC
+Message-Id: <2E0E3E9E-BFA8-49F1-B0DA-9F77CD81D268@foo.bar>
+X-Universally-Unique-Identifier: 176E61BD-DBD7-4F8B-ADB6-7AE7E87F3EF8
+Date: Thu, 5 Mar 2015 11:13:11 -0800
+To: user2@foo.bar
+Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\))
+
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC
+Content-Transfer-Encoding: 7bit
+Content-Type: text/plain;
+	charset=us-ascii
+
+Is this message signed?
+
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC
+Content-Disposition: attachment;
+	filename=smime.p7s
+Content-Type: application/pkcs7-signature;
+	name=smime.p7s
+Content-Transfer-Encoding: base64
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAgAAoIIC4DCCAtww
+ggHEoAMCAQICAhI7MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEW
+MBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN
+U21pbWUgVGVzdCBDQTAgFw0xNTAyMjgwMDIyMThaGA8yMjg4MTIxMzAwMjIxOFowHjEcMBoGCSqG
+SIb3DQEJARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC3mZ5kYq5ZA
+9SFWu4USCug1YqPs7W9lLnsMckB2EysfCbPBiEiKn6Nw3wQglJyr0BWaOIet62wO6mvyMKrFQuaj
+gagwgaUwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlm
+aWNhdGUwHQYDVR0OBBYEFJturmeUXWH7c2NxRIzg8uy6Q8ugMB8GA1UdIwQYMBaAFAH6hwo7a1GW
+3Lakpx9Ic99KGP2eMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQw
+DQYJKoZIhvcNAQELBQADggEBAKh6rrijzgAknggRAW2HMe/bAUrpI6vBCMM+C1DnxQxXCtgu1Qps
+d4lMJAFOv7BfVdSSzJwBOvE6SmA85mOn7TD2v1VjkBx5CQM0FOZ6SMpmsrFRkJnnCuJiCW+/8Ua7
+tas6avHqCUvJ7h8+/R1TTNJUP5hVRd9Ofg1yXnSGJCBXp5OYz32M7meFI3J300+Jd+BU93rhD5Ez
+hSbluyVKpCH3Ami9e/qW13ckgdaMF2MxKjnb/ck3yldmthMBpFmTrDJ6Ax/+swSkfjtUaNXV8yZk
+uSSDWIWaQ2Yepa4X1dOaDNzj5oVnfHReZPZLNzN+/ZA7M34qRp80550QuWsNTIcxggIlMIICIQIB
+ATBoMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW
+MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjswCQYFKw4D
+AhoFAKCCAVEwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMTUwMzA1
+MTkxMzExWjAjBgkqhkiG9w0BCQQxFgQU4IyE3V2uqhatrXWX5Kr3T5UMTm8wdwYJKwYBBAGCNxAE
+MWowaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx
+FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7MHkGCyqG
+SIb3DQEJEAILMWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50
+YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EC
+AhI7MAkGByqGSM49AgEERzBFAiA5H6auafdyvrv03b04pqIHjIsXoaGwwZjGpTInX/4j5AIhALad
+Nyyxy6gJrXIeF3GaUJKsMliXHrppjhZp7Piev/+pAAAAAAAA
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC--
diff --git a/crypto/default/cms/testdata/signed-unknown-hash-algorithm.msg b/crypto/default/cms/testdata/signed-unknown-hash-algorithm.msg
new file mode 100644
index 0000000..aa78792
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-unknown-hash-algorithm.msg
@@ -0,0 +1,55 @@
+From: Test <user1@foo.bar>
+Content-Type: multipart/signed;
+	boundary="Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC";
+	protocol="application/pkcs7-signature";
+	micalg=sha1
+X-Smtp-Server: 127.0.0.1
+Subject: Signed with ECC
+Message-Id: <2E0E3E9E-BFA8-49F1-B0DA-9F77CD81D268@foo.bar>
+X-Universally-Unique-Identifier: 176E61BD-DBD7-4F8B-ADB6-7AE7E87F3EF8
+Date: Thu, 5 Mar 2015 11:13:11 -0800
+To: user2@foo.bar
+Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\))
+
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC
+Content-Transfer-Encoding: 7bit
+Content-Type: text/plain;
+	charset=us-ascii
+
+Is this message signed?
+
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC
+Content-Disposition: attachment;
+	filename=smime.p7s
+Content-Type: application/pkcs7-signature;
+	name=smime.p7s
+Content-Transfer-Encoding: base64
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIC4DCCAtww
+ggHEoAMCAQICAhI7MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEW
+MBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN
+U21pbWUgVGVzdCBDQTAgFw0xNTAyMjgwMDIyMThaGA8yMjg4MTIxMzAwMjIxOFowHjEcMBoGCSqG
+SIb3DQEJARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC3mZ5kYq5ZA
+9SFWu4USCug1YqPs7W9lLnsMckB2EysfCbPBiEiKn6Nw3wQglJyr0BWaOIet62wO6mvyMKrFQuaj
+gagwgaUwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlm
+aWNhdGUwHQYDVR0OBBYEFJturmeUXWH7c2NxRIzg8uy6Q8ugMB8GA1UdIwQYMBaAFAH6hwo7a1GW
+3Lakpx9Ic99KGP2eMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQw
+DQYJKoZIhvcNAQELBQADggEBAKh6rrijzgAknggRAW2HMe/bAUrpI6vBCMM+C1DnxQxXCtgu1Qps
+d4lMJAFOv7BfVdSSzJwBOvE6SmA85mOn7TD2v1VjkBx5CQM0FOZ6SMpmsrFRkJnnCuJiCW+/8Ua7
+tas6avHqCUvJ7h8+/R1TTNJUP5hVRd9Ofg1yXnSGJCBXp5OYz32M7meFI3J300+Jd+BU93rhD5Ez
+hSbluyVKpCH3Ami9e/qW13ckgdaMF2MxKjnb/ck3yldmthMBpFmTrDJ6Ax/+swSkfjtUaNXV8yZk
+uSSDWIWaQ2Yepa4X1dOaDNzj5oVnfHReZPZLNzN+/ZA7M34qRp80550QuWsNTIcxggIlMIICIQIB
+ATBoMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW
+MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjswCQYFLA4D
+AhoFAKCCAVEwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMTUwMzA1
+MTkxMzExWjAjBgkqhkiG9w0BCQQxFgQU4IyE3V2uqhatrXWX5Kr3T5UMTm8wdwYJKwYBBAGCNxAE
+MWowaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx
+FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7MHkGCyqG
+SIb3DQEJEAILMWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50
+YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EC
+AhI7MAkGByqGSM49AgEERzBFAiA5H6auafdyvrv03b04pqIHjIsXoaGwwZjGpTInX/4j5AIhALad
+Nyyxy6gJrXIeF3GaUJKsMliXHrppjhZp7Piev/+pAAAAAAAA
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC--
diff --git a/crypto/default/cms/testdata/signed-unknown-signature-algorithm.msg b/crypto/default/cms/testdata/signed-unknown-signature-algorithm.msg
new file mode 100644
index 0000000..1dba888
--- /dev/null
+++ b/crypto/default/cms/testdata/signed-unknown-signature-algorithm.msg
@@ -0,0 +1,55 @@
+From: Test <user1@foo.bar>
+Content-Type: multipart/signed;
+	boundary="Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC";
+	protocol="application/pkcs7-signature";
+	micalg=sha1
+X-Smtp-Server: 127.0.0.1
+Subject: Signed with ECC
+Message-Id: <2E0E3E9E-BFA8-49F1-B0DA-9F77CD81D268@foo.bar>
+X-Universally-Unique-Identifier: 176E61BD-DBD7-4F8B-ADB6-7AE7E87F3EF8
+Date: Thu, 5 Mar 2015 11:13:11 -0800
+To: user2@foo.bar
+Mime-Version: 1.0 (Mac OS X Mail 7.3 \(1878.6\))
+
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC
+Content-Transfer-Encoding: 7bit
+Content-Type: text/plain;
+	charset=us-ascii
+
+Is this message signed?
+
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC
+Content-Disposition: attachment;
+	filename=smime.p7s
+Content-Type: application/pkcs7-signature;
+	name=smime.p7s
+Content-Transfer-Encoding: base64
+
+MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIC4DCCAtww
+ggHEoAMCAQICAhI7MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEW
+MBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN
+U21pbWUgVGVzdCBDQTAgFw0xNTAyMjgwMDIyMThaGA8yMjg4MTIxMzAwMjIxOFowHjEcMBoGCSqG
+SIb3DQEJARYNdXNlcjFAZm9vLmJhcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC3mZ5kYq5ZA
+9SFWu4USCug1YqPs7W9lLnsMckB2EysfCbPBiEiKn6Nw3wQglJyr0BWaOIet62wO6mvyMKrFQuaj
+gagwgaUwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlm
+aWNhdGUwHQYDVR0OBBYEFJturmeUXWH7c2NxRIzg8uy6Q8ugMB8GA1UdIwQYMBaAFAH6hwo7a1GW
+3Lakpx9Ic99KGP2eMAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQw
+DQYJKoZIhvcNAQELBQADggEBAKh6rrijzgAknggRAW2HMe/bAUrpI6vBCMM+C1DnxQxXCtgu1Qps
+d4lMJAFOv7BfVdSSzJwBOvE6SmA85mOn7TD2v1VjkBx5CQM0FOZ6SMpmsrFRkJnnCuJiCW+/8Ua7
+tas6avHqCUvJ7h8+/R1TTNJUP5hVRd9Ofg1yXnSGJCBXp5OYz32M7meFI3J300+Jd+BU93rhD5Ez
+hSbluyVKpCH3Ami9e/qW13ckgdaMF2MxKjnb/ck3yldmthMBpFmTrDJ6Ax/+swSkfjtUaNXV8yZk
+uSSDWIWaQ2Yepa4X1dOaDNzj5oVnfHReZPZLNzN+/ZA7M34qRp80550QuWsNTIcxggIlMIICIQIB
+ATBoMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEW
+MBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjswCQYFKw4D
+AhoFAKCCAVEwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMTUwMzA1
+MTkxMzExWjAjBgkqhkiG9w0BCQQxFgQU4IyE3V2uqhatrXWX5Kr3T5UMTm8wdwYJKwYBBAGCNxAE
+MWowaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx
+FjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI7MHkGCyqG
+SIb3DQEJEAILMWqgaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50
+YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0EC
+AhI7MAkGByuGSM49AgEERzBFAiA5H6auafdyvrv03b04pqIHjIsXoaGwwZjGpTInX/4j5AIhALad
+Nyyxy6gJrXIeF3GaUJKsMliXHrppjhZp7Piev/+pAAAAAAAA
+
+--Apple-Mail=_F7FDF2C6-45E2-4306-BF2E-0F2A24641BDC--
diff --git a/crypto/default/cms/testdata/two-signatures-openssl-detached.msg b/crypto/default/cms/testdata/two-signatures-openssl-detached.msg
new file mode 100644
index 0000000..3738509
--- /dev/null
+++ b/crypto/default/cms/testdata/two-signatures-openssl-detached.msg
@@ -0,0 +1,107 @@
+MIME-Version: 1.0
+Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----B206BF1D504B79DB9C6B134A1649A155"
+
+This is an S/MIME signed message
+
+------B206BF1D504B79DB9C6B134A1649A155
+Content-Type: text/plain
+
+Content-Type: text/plain
+
+This is a message.
+There are many like it, but this one is mine.
+
+------B206BF1D504B79DB9C6B134A1649A155
+Content-Type: application/x-pkcs7-signature; name="smime.p7s"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="smime.p7s"
+
+MIIP/QYJKoZIhvcNAQcCoIIP7jCCD+oCAQExDzANBglghkgBZQMEAgEFADALBgkq
+hkiG9w0BBwGgggrrMIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3DQEB
+CwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRh
+aW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUg
+VGVzdCBDQTAgFw0xNTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjELMAkG
+A1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYw
+FAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMIIB
+IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9AiYIFq
+rOPrQTuOHS3siZJ+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRLH1bo
+Mdj0GYSTwk/Jv49xsUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqWXs4B
+AhIGE2Hp3fhJjy384WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX3qzv
+31EXDfBm4sfcbZwZAQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9etO+
+ppjouHY5dVK6Enkx+dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8URwID
+AQABo1AwTjAdBgNVHQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0jBBgw
+FoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B
+AQsFAAOCAQEAGaVllmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdfvVvV
+JxURELclnw7ftoIhdm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0DgDKgJ
+mPF+roauluLXHbcNXe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHqXYOY
+CRZ8KABwVmXbucqD1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK23SFC
+kpy52f4PmFEP/q6GJzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI84xs
+VYCL3eEmgjgbnPghhdWuyA3tc/Wsy6ZNkzCCA6MwggKLoAMCAQICAhI1MA0GCSqG
+SIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwN
+TW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwN
+U21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1Mlow
+ADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2xBzp
+emUy4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoKLTQf
+DjbYNB/L37tANWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQV6vn
+7fFUvLFbA7+9t0mxEYwiYkx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUKZ304
+iJhsgtY8HwnA8FV3NoI87Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3nL7C1
+rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRrmk+Q
+W7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NM
+IEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2xe4T
+PXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQD
+AgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAPgQ11
+c2VyMUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert48r6g
+wYVu2BYXUjrjI5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EBkRr5
+OJWHEoByWNr+XkS0mUeRED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg6bI9
+9JWnZfLFcgGmqUosksfcxcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyDBNqx
+6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWnmlzO
+eYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukjKl8D
+MIIDozCCAougAwIBAgICEjYwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAw
+MzAwM1oYDzIyODgxMTI1MDAzMDAzWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAygTQ+T7qURHYyhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSkaTnp
+pXmr6nvzHIxsVZ2FohK2sqHHQtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU8F1w
+u4KhCeNaZ23xfGb4UrqfLxkVViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno3iiu
+NVYGU/YC8NGBU+ZkrB8VllKJyt916wKvN/I7sDgONk68KceP03HNvC0msV2+j0f/
+20olmx/wvzJAbrObOzQ/XDM730onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/aYv45
+TmNZMFE4dPBMYaWErJpk88OSj9RP80GDPwIDAQABo4HCMIG/MAkGA1UdEwQCMAAw
+LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G
+A1UdDgQWBBQhRBpKe5lzyXs+xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocKO2tR
+lty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG
+CCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIyQGZvby5iYXIwDQYJKoZIhvcNAQEL
+BQADggEBAEuJfaW7Rq3qyphR3BVnZ1aColHkQLcEsddzbf57GWJjrVbwvV1UIeMA
+f0H2UCsc+8GLYcXCvy34IultcxGL7g6n7wWnfCV/lY0Jd5eRQSit0BGygQdib7cT
+20JJy/3et2UnekVHnJhrEv5CigwaOs9HkIL9C3XJHqYYHyHL6QTLFp5JnxqV4YWU
+hq16Jfk5no7BA6Sfhpn2cyn4rGoHUKmFBeN9VePpV2aKRZ31HqEtSHKZmxZXAzbY
+SrIXYRBsMVab+TnwNkiQXAZD73JbboblOiXgNUbxe2/aLcRNW7uY42DRU1QGR9Ww
+edKgv9ZRRDmd3vSSrdP5X9BZNDNOhtcxggTWMIICWAIBATBoMGIxCzAJBgNVBAYT
+AlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQGA1UE
+CgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjYwDQYJ
+YIZIAWUDBAIBBQCggcYwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAvBgkqhkiG
+9w0BCQQxIgQgNIkQuj6NwdURYu6dxjTIoLVXR5rpXFksBE3IUBPWztEweQYJKoZI
+hvcNAQkPMWwwajALBglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQB
+AjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYF
+Kw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAs64WiItCT6GU
+hM2COtpThWxNeQZdR6Mzk7IzctoGr7nDjWEOhR7yWw/uT9F98XnIR2+wLcHMTtNH
+qaxLax7BUI0xmAJk6hsDfYfZyz0DxGKHH5b1/rO+hD81ao0Wf+3eu+OJHp1FoRYb
+RLEWzDnPFFSYjgJ7MJ8vKaTA77ibQfniq2O25EbGnW/A4LZ3KZIbfB2mqgJvPmJF
+2N4yf35Voke2X4APsJB/LEjMbS72QIlaAYFqNw1UR+9dE9NgOGs0LrrHNQ8Ebi1g
+mzx2GIZF5pS2gViURaec8UKy7IXL0c5+NbuJBAgJmYVA3RpvoO/58GiFiffohrHE
+YIofgJWi6zCCAnYCAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAU
+BgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0ExFjAU
+BgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUAoIHkMBgGCSqG
+SIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMDE4MjUy
+OVowLwYJKoZIhvcNAQkEMSIEIDSJELo+jcHVEWLuncY0yKC1V0ea6VxZLARNyFAT
+1s7RMHkGCSqGSIb3DQEJDzFsMGowCwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQBFjAL
+BglghkgBZQMEAQIwCgYIKoZIhvcNAwcwDgYIKoZIhvcNAwICAgCAMA0GCCqGSIb3
+DQMCAgFAMAcGBSsOAwIHMA0GCCqGSIb3DQMCAgEoMA0GCSqGSIb3DQEBAQUABIIB
+AG1qk8Dp+u6wGtF2CHl1+KwnIYB0r+fe9xFSCzrJkVmqYn4ArQfP0MXsp0mVDIlr
+Ud6oe3BvXKhbH49dqOIUS+/p05bV+/ncK/p6zS8NtvV6yNw9rKKuqFJj/n1WxUS8
+5Ys8SbH3gVCsa6BtYgdb58kWWkHVq1JbfPwmMcC3Azm8hMLemqVMCDk/Ni4uoW8u
+GualhdpNrrJpSV+2drRRvZ7RBmm+lAsrQKlSpMq8d13AgYLTBKW3GNVWITOkaTAs
+UNCfYXbVOb8wDe5sMBS1/j2tHC6jkUnZ23e8XGT962EaWuTXqwc1Mv+kpDlzb70F
+JcdC69k6LSNMisrJQvgN0uo=
+
+------B206BF1D504B79DB9C6B134A1649A155--
+
diff --git a/crypto/default/cms/testdata/two-signatures-openssl-opaque.msg b/crypto/default/cms/testdata/two-signatures-openssl-opaque.msg
new file mode 100644
index 0000000..01da657
--- /dev/null
+++ b/crypto/default/cms/testdata/two-signatures-openssl-opaque.msg
@@ -0,0 +1,94 @@
+MIME-Version: 1.0
+Content-Disposition: attachment; filename="smime.p7m"
+Content-Type: application/x-pkcs7-mime; smime-type=signed-data; name="smime.p7m"
+Content-Transfer-Encoding: base64
+
+MIIQYAYJKoZIhvcNAQcCoIIQUTCCEE0CAQExDzANBglghkgBZQMEAgEFADBuBgkq
+hkiG9w0BBwGgYQRfQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNClRoaXMgaXMg
+YSBtZXNzYWdlLg0KVGhlcmUgYXJlIG1hbnkgbGlrZSBpdCwgYnV0IHRoaXMgb25l
+IGlzIG1pbmUuDQqgggrrMIIDmTCCAoGgAwIBAgIJAKOHSyyc6KGQMA0GCSqGSIb3
+DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91
+bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21p
+bWUgVGVzdCBDQTAgFw0xNTAyMDYxODA0MjFaGA8yMjg4MTEyMTE4MDQyMVowYjEL
+MAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3
+MRYwFAYDVQQKDA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENB
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzx6SgBOPKdSydEk6A9Ai
+YIFqrOPrQTuOHS3siZJ+Kuafn/27o4fRTzA3kvZldsU9RTRKg0J05zmWZObUbiRL
+H1boMdj0GYSTwk/Jv49xsUxhJjSq/dOUJHtpo/kNAqCQBj6tZp+l6zz5yH1MGXqW
+Xs4BAhIGE2Hp3fhJjy384WpSzVMq89ysLzVW9cwdHk0/bnIp/qigAQKA8eKO6ZcX
+3qzv31EXDfBm4sfcbZwZAQCFRJovHjzvH9TKxv2KL4dVdGHKdA+73TGv6zb1aJu9
+etO+ppjouHY5dVK6Enkx+dk84CTaABOqFzGT0q6r+c3isRUivME0t+aKNyGMhI8U
+RwIDAQABo1AwTjAdBgNVHQ4EFgQUAfqHCjtrUZbctqSnH0hz30oY/Z4wHwYDVR0j
+BBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wDAYDVR0TBAUwAwEB/zANBgkqhkiG
+9w0BAQsFAAOCAQEAGaVllmpKfBLo/RROx45F33tPXiJTsl8VC+dwRZViK91TvSdf
+vVvVJxURELclnw7ftoIhdm5pRarEYCW9+WtXzKrAgImi0fPhZ9AGEUF++K/0J0Dg
+DKgJmPF+roauluLXHbcNXe8GbApeBlcGMBs3B/fjO5FxQnM+u1jliWOEwrMbxVHq
+XYOYCRZ8KABwVmXbucqD1xrqVJvSS8LAvxXDhx8er6b00Kx6w8u4fGDspmx3SrK2
+3SFCkpy52f4PmFEP/q6GJzvBw43Dg7KiIMgnHTMTjhGoBHKtaodKRnsGBZ4muNEI
+84xsVYCL3eEmgjgbnPghhdWuyA3tc/Wsy6ZNkzCCA6MwggKLoAMCAQICAhI1MA0G
+CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UE
+BwwNTW91bnRhaW4gVmlldzEWMBQGA1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UE
+AwwNU21pbWUgVGVzdCBDQTAgFw0xNTAyMTAwMDI4NTJaGA8yMjg4MTEyNTAwMjg1
+MlowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAONevfySnSbRY3C2
+xBzpemUy4teY0brqNg1dFRnLPhjOfSR4IB80FRxJwvIKZ+XB0cineRN7zHF/nXoK
+LTQfDjbYNB/L37tANWJO+LRbi6ipNDsZP1uY8E/XdUVHJK8hbTByNOT2hGvhcnVQ
+V6vn7fFUvLFbA7+9t0mxEYwiYkx0Oo241vBfoxO4nKQPacr25YKOLtTnoDxZaFUK
+Z304iJhsgtY8HwnA8FV3NoI87Q1hSXmoecLgBAP/Vm2xm27NJUurq7gG5zJp0b3n
+L7C1rCPvLJqRzGCyQQmbpLQ8NNi328SAAuduTBdo2HVOtP2z0mfQqD6yxf4UWgRr
+mk+QW7MCAwEAAaOBwjCBvzAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu
+U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU4eMu43mu8GQYLez2
+xe4TPXN8E50wHwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0P
+BAQDAgXgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAYBgNVHREEETAP
+gQ11c2VyMUBmb28uYmFyMA0GCSqGSIb3DQEBCwUAA4IBAQBv9iEnGt+MXOJ4Ert4
+8r6gwYVu2BYXUjrjI5eNYNSX2sjHVfuYkv+xrLDeKoxIooO3FjPNmH0zDNkoT4EB
+kRr5OJWHEoByWNr+XkS0mUeRED1SFpSjDNFX3QDwkc3faSaQKrfgxia2DjvaPNmg
+6bI99JWnZfLFcgGmqUosksfcxcpW1uRL6fKRPQHfQW/L5PbtYZQtGiUFUVN6uFyD
+BNqx6rtf4hfQ+057TSRepOgCQ/whjuOb8ZxRYLjGOUTt8aHxBkLL8tPzdnX/qYWn
+mlzOeYB5itIY6pcImNiO8GVedfv1/LMx3+m0RkVkaHhoqg/fxymKrRgCuYZBmukj
+Kl8DMIIDozCCAougAwIBAgICEjYwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMC
+VVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQK
+DA1TbWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIx
+MDAwMzAwM1oYDzIyODgxMTI1MDAzMDAzWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOC
+AQ8AMIIBCgKCAQEAygTQ+T7qURHYyhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSk
+aTnppXmr6nvzHIxsVZ2FohK2sqHHQtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU
+8F1wu4KhCeNaZ23xfGb4UrqfLxkVViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno
+3iiuNVYGU/YC8NGBU+ZkrB8VllKJyt916wKvN/I7sDgONk68KceP03HNvC0msV2+
+j0f/20olmx/wvzJAbrObOzQ/XDM730onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/a
+Yv45TmNZMFE4dPBMYaWErJpk88OSj9RP80GDPwIDAQABo4HCMIG/MAkGA1UdEwQC
+MAAwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRl
+MB0GA1UdDgQWBBQhRBpKe5lzyXs+xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocK
+O2tRlty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUH
+AwIGCCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIyQGZvby5iYXIwDQYJKoZIhvcN
+AQELBQADggEBAEuJfaW7Rq3qyphR3BVnZ1aColHkQLcEsddzbf57GWJjrVbwvV1U
+IeMAf0H2UCsc+8GLYcXCvy34IultcxGL7g6n7wWnfCV/lY0Jd5eRQSit0BGygQdi
+b7cT20JJy/3et2UnekVHnJhrEv5CigwaOs9HkIL9C3XJHqYYHyHL6QTLFp5JnxqV
+4YWUhq16Jfk5no7BA6Sfhpn2cyn4rGoHUKmFBeN9VePpV2aKRZ31HqEtSHKZmxZX
+AzbYSrIXYRBsMVab+TnwNkiQXAZD73JbboblOiXgNUbxe2/aLcRNW7uY42DRU1QG
+R9WwedKgv9ZRRDmd3vSSrdP5X9BZNDNOhtcxggTWMIICWAIBATBoMGIxCzAJBgNV
+BAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEWMBQG
+A1UECgwNU21pbWUgVGVzdCBDQTEWMBQGA1UEAwwNU21pbWUgVGVzdCBDQQICEjYw
+DQYJYIZIAWUDBAIBBQCggcYwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAvBgkq
+hkiG9w0BCQQxIgQgNIkQuj6NwdURYu6dxjTIoLVXR5rpXFksBE3IUBPWztEweQYJ
+KoZIhvcNAQkPMWwwajALBglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFl
+AwQBAjAKBggqhkiG9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAw
+BwYFKw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAs64WiItC
+T6GUhM2COtpThWxNeQZdR6Mzk7IzctoGr7nDjWEOhR7yWw/uT9F98XnIR2+wLcHM
+TtNHqaxLax7BUI0xmAJk6hsDfYfZyz0DxGKHH5b1/rO+hD81ao0Wf+3eu+OJHp1F
+oRYbRLEWzDnPFFSYjgJ7MJ8vKaTA77ibQfniq2O25EbGnW/A4LZ3KZIbfB2mqgJv
+PmJF2N4yf35Voke2X4APsJB/LEjMbS72QIlaAYFqNw1UR+9dE9NgOGs0LrrHNQ8E
+bi1gmzx2GIZF5pS2gViURaec8UKy7IXL0c5+NbuJBAgJmYVA3RpvoO/58GiFiffo
+hrHEYIofgJWi6zCCAnYCAQEwaDBiMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0Ex
+FjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFjAUBgNVBAoMDVNtaW1lIFRlc3QgQ0Ex
+FjAUBgNVBAMMDVNtaW1lIFRlc3QgQ0ECAhI1MA0GCWCGSAFlAwQCAQUAoIHkMBgG
+CSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTE1MDIxMDE4
+MjQ0NVowLwYJKoZIhvcNAQkEMSIEIDSJELo+jcHVEWLuncY0yKC1V0ea6VxZLARN
+yFAT1s7RMHkGCSqGSIb3DQEJDzFsMGowCwYJYIZIAWUDBAEqMAsGCWCGSAFlAwQB
+FjALBglghkgBZQMEAQIwCgYIKoZIhvcNAwcwDgYIKoZIhvcNAwICAgCAMA0GCCqG
+SIb3DQMCAgFAMAcGBSsOAwIHMA0GCCqGSIb3DQMCAgEoMA0GCSqGSIb3DQEBAQUA
+BIIBADI+8+4mqgv0zPwUenNTbav+2n7oT2wy/5a0Zpp5Dmn08H6Azf9zV91e3ihq
+M90Jhb2KVapTH4+T+zOsM2oP3by1oUrUM3/RXPtjoW5yo/K37zSo2Gh2f9C11jFs
+REAv8pk6lpwIxBSWBI4Ivhju3xKJ/ZJCpre5ofJCzqfSyG8Jfd+LJBeXhWqCw6jM
+sDdqPIK3n+ujeIbJam8AGSrCQ7MQqZZl1mU9zed3wihhgopqo5hntaYArG5atOsU
+YN0MI1XEAu+NtmMegY0J3Rxm6B2H79AvrfGwY3+tz5MNUxyFGVGOM4kLJ0vfClH+
+UcaKSxT8MPwB7JqNg7DGN9NDRWU=
+
diff --git a/crypto/default/cms/testdata/user1.key b/crypto/default/cms/testdata/user1.key
new file mode 100644
index 0000000..7eb2f6c
--- /dev/null
+++ b/crypto/default/cms/testdata/user1.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEA4169/JKdJtFjcLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHgg
+HzQVHEnC8gpn5cHRyKd5E3vMcX+degotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/
+W5jwT9d1RUckryFtMHI05PaEa+FydVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW
+8F+jE7icpA9pyvblgo4u1OegPFloVQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5
+wuAEA/9WbbGbbs0lS6uruAbnMmnRvecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC
+525MF2jYdU60/bPSZ9CoPrLF/hRaBGuaT5BbswIDAQABAoIBAQDENkJl8HBFy3Sx
+FEfxW1sQcfVocKytTOkQZz+KXuZbqfY8vp2Zz+xX2R+umGRI1hYfvsaxlag8WhCf
+/KA91n8BvVpeBiYxBZwMFnJx0n6XMk2sooEWnsjjXeAJxPdXWtVLdYNIA6ULJCcO
+LiM6ao/gL2wW8aAum53Mrk2iTRnfNt3KEhKpMdKMlq4R01mVBVEEEGPXDvkrbNRg
+d2uMoNooUo6X7339tX/iZZFnbyT+oen7mdzB7L0juT+ZAXKx8imvqr3LEHrQ9nz8
+9ah2+bnGCk3u19x28i8QBCO0MN8MbLccVc2WAvf50jppmpQ42IZifKsJVp2AzpTt
+9Z5lPIXBAoGBAPVhnhv3ZkprY5PBYtppBYVFsax0dDgryONIGybpOIixyv2wVTJ7
+IsP125LaizNTMDuH9F6i1vhHxR8gAoDnKngasx2kWs5W3uno9sCA/UahwdzxKe4t
+qVaGb9S6aZTOT2uzxZF2dW6dgbPAiMN+JefqRYJn6Fge+ZuDdnTezYNJAoGBAO01
+l7G96QQS4U68ZEQGIfHpdYFGWO9APx3EsVkTQ8/Xp3IW3Vr61uRd2FlzzNQCVKXh
+5dLBslKU7TuMU2qdfEaFtG8NnVUNp2njqlucayKSZkpshoGhCNpHjUcqc+X1wAlT
+0yYoWL7IdGMHD21YddIIo0IOnzvPqOLXBZEV5GsbAoGAQU85CNKBLD+G4lWvXNhk
++sLA9liMckLdFfkkGOgr/t3rXDgphOUXLLbsORrnv8JdbQrTdLi8pJpdkdowMGTW
+fd8FW1daP98JN5Kk0jnCr6Dv+0FI2rbWgkwHycPPxCZhJxZS+FNiEA3l7pTJEdcq
+eZ51366OPZ4I62ZDCfofEtkCgYA6c2tGxAooE9LrCOcYD/7LUoC+8yu1ALrMxgwB
+bArYq0Y6icHPymGrcih+mldNoqljGs/75/jd+o6ejsx7SAU4h3fXqZ4sOCoZoJz4
+6AlKQpLsIePfC2RYDSuV7rMSZkUEx2APVf/QOyrawAGgKx0VPUWlYF8CaQAvrVZU
+0vUvIwKBgDL5Wb+AHv1ffWJkPYPiR6JyEEpWExBIuZc9L3+YXCIzJDk7nVJfYUrb
+M0Yl6DVgISn/joxEOuvFeq+De88WIoKxXcQo6oFVOihAgooA3AncpHuLIY0cy8oS
+1MFE8Ms4p4RpVZYedYOIugIU6sS3V6vFXe9L1Rr5Edji4cg6qYQ6
+-----END RSA PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user1_bad_public_key.pem b/crypto/default/cms/testdata/user1_bad_public_key.pem
new file mode 100644
index 0000000..f536e93
--- /dev/null
+++ b/crypto/default/cms/testdata/user1_bad_public_key.pem
@@ -0,0 +1,69 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4661 (0x1235)
+        Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C = US, ST = CA, L = Mountain View, O = Smime Test CA, CN = Smime Test CA
+        Validity
+            Not Before: Feb 10 00:28:52 2015 GMT
+            Not After : Nov 25 00:28:52 2288 GMT
+        Subject: 
+        Subject Public Key Info:
+            Public Key Algorithm: 1.2.840.113554.4.1.72585.2
+            Unable to load Public Key
+140710245086528:error:0609E09C:digital envelope routines:pkey_set_type:unsupported algorithm:../crypto/evp/p_lib.c:210:
+140710245086528:error:0B09406F:x509 certificate routines:x509_pubkey_decode:unsupported algorithm:../crypto/x509/x_pubkey.c:114:
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                E1:E3:2E:E3:79:AE:F0:64:18:2D:EC:F6:C5:EE:13:3D:73:7C:13:9D
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+            X509v3 Subject Alternative Name: 
+                email:user1@foo.bar
+    Signature Algorithm: sha256WithRSAEncryption
+         6f:f6:21:27:1a:df:8c:5c:e2:78:12:bb:78:f2:be:a0:c1:85:
+         6e:d8:16:17:52:3a:e3:23:97:8d:60:d4:97:da:c8:c7:55:fb:
+         98:92:ff:b1:ac:b0:de:2a:8c:48:a2:83:b7:16:33:cd:98:7d:
+         33:0c:d9:28:4f:81:01:91:1a:f9:38:95:87:12:80:72:58:da:
+         fe:5e:44:b4:99:47:91:10:3d:52:16:94:a3:0c:d1:57:dd:00:
+         f0:91:cd:df:69:26:90:2a:b7:e0:c6:26:b6:0e:3b:da:3c:d9:
+         a0:e9:b2:3d:f4:95:a7:65:f2:c5:72:01:a6:a9:4a:2c:92:c7:
+         dc:c5:ca:56:d6:e4:4b:e9:f2:91:3d:01:df:41:6f:cb:e4:f6:
+         ed:61:94:2d:1a:25:05:51:53:7a:b8:5c:83:04:da:b1:ea:bb:
+         5f:e2:17:d0:fb:4e:7b:4d:24:5e:a4:e8:02:43:fc:21:8e:e3:
+         9b:f1:9c:51:60:b8:c6:39:44:ed:f1:a1:f1:06:42:cb:f2:d3:
+         f3:76:75:ff:a9:85:a7:9a:5c:ce:79:80:79:8a:d2:18:ea:97:
+         08:98:d8:8e:f0:65:5e:75:fb:f5:fc:b3:31:df:e9:b4:46:45:
+         64:68:78:68:aa:0f:df:c7:29:8a:ad:18:02:b9:86:41:9a:e9:
+         23:2a:5f:03
+-----BEGIN CERTIFICATE-----
+MIIDpjCCAo6gAwIBAgICEjUwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAw
+Mjg1MloYDzIyODgxMTI1MDAyODUyWjAAMIIBJTAQBgwqhkiG9xIEAYS3CQIFAAOC
+AQ8AMIIBCgKCAQEA4169/JKdJtFjcLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHgg
+HzQVHEnC8gpn5cHRyKd5E3vMcX+degotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/
+W5jwT9d1RUckryFtMHI05PaEa+FydVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW
+8F+jE7icpA9pyvblgo4u1OegPFloVQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5
+wuAEA/9WbbGbbs0lS6uruAbnMmnRvecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC
+525MF2jYdU60/bPSZ9CoPrLF/hRaBGuaT5BbswIDAQABo4HCMIG/MAkGA1UdEwQC
+MAAwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRl
+MB0GA1UdDgQWBBTh4y7jea7wZBgt7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocK
+O2tRlty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUH
+AwIGCCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIxQGZvby5iYXIwDQYJKoZIhvcN
+AQELBQADggEBAG/2ISca34xc4ngSu3jyvqDBhW7YFhdSOuMjl41g1JfayMdV+5iS
+/7GssN4qjEiig7cWM82YfTMM2ShPgQGRGvk4lYcSgHJY2v5eRLSZR5EQPVIWlKMM
+0VfdAPCRzd9pJpAqt+DGJrYOO9o82aDpsj30ladl8sVyAaapSiySx9zFylbW5Evp
+8pE9Ad9Bb8vk9u1hlC0aJQVRU3q4XIME2rHqu1/iF9D7TntNJF6k6AJD/CGO45vx
+nFFguMY5RO3xofEGQsvy0/N2df+phaeaXM55gHmK0hjqlwiY2I7wZV51+/X8szHf
+6bRGRWRoeGiqD9/HKYqtGAK5hkGa6SMqXwM=
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_both.pem b/crypto/default/cms/testdata/user1_both.pem
new file mode 100644
index 0000000..ca6d320
--- /dev/null
+++ b/crypto/default/cms/testdata/user1_both.pem
@@ -0,0 +1,88 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4664 (0x1238)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Feb 10 18:14:33 2015 GMT
+            Not After : Nov 25 18:14:33 2288 GMT
+        Subject: emailAddress=user1@foo.bar
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+                Public-Key: (2048 bit)
+                Modulus:
+                    00:e3:5e:bd:fc:92:9d:26:d1:63:70:b6:c4:1c:e9:
+                    7a:65:32:e2:d7:98:d1:ba:ea:36:0d:5d:15:19:cb:
+                    3e:18:ce:7d:24:78:20:1f:34:15:1c:49:c2:f2:0a:
+                    67:e5:c1:d1:c8:a7:79:13:7b:cc:71:7f:9d:7a:0a:
+                    2d:34:1f:0e:36:d8:34:1f:cb:df:bb:40:35:62:4e:
+                    f8:b4:5b:8b:a8:a9:34:3b:19:3f:5b:98:f0:4f:d7:
+                    75:45:47:24:af:21:6d:30:72:34:e4:f6:84:6b:e1:
+                    72:75:50:57:ab:e7:ed:f1:54:bc:b1:5b:03:bf:bd:
+                    b7:49:b1:11:8c:22:62:4c:74:3a:8d:b8:d6:f0:5f:
+                    a3:13:b8:9c:a4:0f:69:ca:f6:e5:82:8e:2e:d4:e7:
+                    a0:3c:59:68:55:0a:67:7d:38:88:98:6c:82:d6:3c:
+                    1f:09:c0:f0:55:77:36:82:3c:ed:0d:61:49:79:a8:
+                    79:c2:e0:04:03:ff:56:6d:b1:9b:6e:cd:25:4b:ab:
+                    ab:b8:06:e7:32:69:d1:bd:e7:2f:b0:b5:ac:23:ef:
+                    2c:9a:91:cc:60:b2:41:09:9b:a4:b4:3c:34:d8:b7:
+                    db:c4:80:02:e7:6e:4c:17:68:d8:75:4e:b4:fd:b3:
+                    d2:67:d0:a8:3e:b2:c5:fe:14:5a:04:6b:9a:4f:90:
+                    5b:b3
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                E1:E3:2E:E3:79:AE:F0:64:18:2D:EC:F6:C5:EE:13:3D:73:7C:13:9D
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+            X509v3 Subject Alternative Name: 
+                email:user1@foo.bar
+    Signature Algorithm: sha256WithRSAEncryption
+         70:f5:eb:dd:49:85:89:34:66:71:df:b9:30:dd:f7:89:1a:63:
+         6a:68:60:c3:b1:0e:b1:f1:73:66:46:d6:02:d4:58:48:83:49:
+         a3:8d:98:e1:b8:0b:e8:71:e7:cb:8c:b1:15:2e:31:08:bc:17:
+         69:b5:8d:d6:02:27:b9:04:6e:ab:b2:36:28:25:de:b0:49:da:
+         00:b3:2b:25:56:49:52:5a:f0:e5:6a:51:6e:25:f0:a2:bc:7c:
+         e7:0f:aa:ea:10:18:ac:24:95:f5:2c:7b:1c:37:fa:e4:b9:72:
+         32:02:a4:fb:1d:e4:a0:2b:39:76:07:11:04:4a:36:9a:b6:86:
+         a2:77:3f:e7:31:c8:13:35:4c:97:ec:30:fc:3f:d0:23:79:bf:
+         ea:11:6d:b8:97:0c:13:6f:28:76:11:30:5e:b7:a4:2a:d3:d7:
+         f0:ee:9a:19:c9:e4:2d:8a:44:84:95:6e:68:a4:3e:84:4b:30:
+         b9:98:bb:53:1d:5f:55:9e:ff:9e:db:3f:74:a6:6c:30:c8:c6:
+         8f:9a:6d:cc:f3:f9:48:6e:82:5c:a0:a2:13:d7:d3:59:07:18:
+         83:78:be:59:2f:6e:52:20:7d:95:43:8d:ce:e2:ed:76:96:98:
+         5a:5d:ba:cd:f8:b7:b2:dc:62:9d:86:55:4a:45:ee:d7:9b:51:
+         f8:85:d3:68
+-----BEGIN CERTIFICATE-----
+MIIDwTCCAqmgAwIBAgICEjgwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDE4
+MTQzM1oYDzIyODgxMTI1MTgxNDMzWjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMUBm
+b28uYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4169/JKdJtFj
+cLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHggHzQVHEnC8gpn5cHRyKd5E3vMcX+d
+egotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/W5jwT9d1RUckryFtMHI05PaEa+Fy
+dVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW8F+jE7icpA9pyvblgo4u1OegPFlo
+VQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5wuAEA/9WbbGbbs0lS6uruAbnMmnR
+vecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC525MF2jYdU60/bPSZ9CoPrLF/hRa
+BGuaT5BbswIDAQABo4HCMIG/MAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9w
+ZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBTh4y7jea7wZBgt
+7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9njALBgNV
+HQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMBgGA1UdEQQR
+MA+BDXVzZXIxQGZvby5iYXIwDQYJKoZIhvcNAQELBQADggEBAHD1691JhYk0ZnHf
+uTDd94kaY2poYMOxDrHxc2ZG1gLUWEiDSaONmOG4C+hx58uMsRUuMQi8F2m1jdYC
+J7kEbquyNigl3rBJ2gCzKyVWSVJa8OVqUW4l8KK8fOcPquoQGKwklfUsexw3+uS5
+cjICpPsd5KArOXYHEQRKNpq2hqJ3P+cxyBM1TJfsMPw/0CN5v+oRbbiXDBNvKHYR
+MF63pCrT1/DumhnJ5C2KRISVbmikPoRLMLmYu1MdX1We/57bP3SmbDDIxo+abczz
++UhuglygohPX01kHGIN4vlkvblIgfZVDjc7i7XaWmFpdus34t7LcYp2GVUpF7teb
+UfiF02g=
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_cn_only.pem b/crypto/default/cms/testdata/user1_cn_only.pem
new file mode 100644
index 0000000..86135dd
--- /dev/null
+++ b/crypto/default/cms/testdata/user1_cn_only.pem
@@ -0,0 +1,85 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4665 (0x1239)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Feb 10 18:16:16 2015 GMT
+            Not After : Nov 25 18:16:16 2288 GMT
+        Subject: emailAddress=user1@foo.bar
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+                Public-Key: (2048 bit)
+                Modulus:
+                    00:e3:5e:bd:fc:92:9d:26:d1:63:70:b6:c4:1c:e9:
+                    7a:65:32:e2:d7:98:d1:ba:ea:36:0d:5d:15:19:cb:
+                    3e:18:ce:7d:24:78:20:1f:34:15:1c:49:c2:f2:0a:
+                    67:e5:c1:d1:c8:a7:79:13:7b:cc:71:7f:9d:7a:0a:
+                    2d:34:1f:0e:36:d8:34:1f:cb:df:bb:40:35:62:4e:
+                    f8:b4:5b:8b:a8:a9:34:3b:19:3f:5b:98:f0:4f:d7:
+                    75:45:47:24:af:21:6d:30:72:34:e4:f6:84:6b:e1:
+                    72:75:50:57:ab:e7:ed:f1:54:bc:b1:5b:03:bf:bd:
+                    b7:49:b1:11:8c:22:62:4c:74:3a:8d:b8:d6:f0:5f:
+                    a3:13:b8:9c:a4:0f:69:ca:f6:e5:82:8e:2e:d4:e7:
+                    a0:3c:59:68:55:0a:67:7d:38:88:98:6c:82:d6:3c:
+                    1f:09:c0:f0:55:77:36:82:3c:ed:0d:61:49:79:a8:
+                    79:c2:e0:04:03:ff:56:6d:b1:9b:6e:cd:25:4b:ab:
+                    ab:b8:06:e7:32:69:d1:bd:e7:2f:b0:b5:ac:23:ef:
+                    2c:9a:91:cc:60:b2:41:09:9b:a4:b4:3c:34:d8:b7:
+                    db:c4:80:02:e7:6e:4c:17:68:d8:75:4e:b4:fd:b3:
+                    d2:67:d0:a8:3e:b2:c5:fe:14:5a:04:6b:9a:4f:90:
+                    5b:b3
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                E1:E3:2E:E3:79:AE:F0:64:18:2D:EC:F6:C5:EE:13:3D:73:7C:13:9D
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+    Signature Algorithm: sha256WithRSAEncryption
+         c3:cc:70:5c:23:0f:d8:b4:35:75:48:7d:01:6c:21:fb:23:06:
+         b3:c0:fc:e1:14:57:2f:93:98:57:66:48:03:6c:4b:a4:61:b9:
+         d1:b2:69:1c:23:1a:2a:9d:ed:d5:3d:22:61:88:b9:f7:f4:3a:
+         b9:8b:e6:02:74:5f:67:de:84:4d:73:4d:c1:e0:dd:f6:e6:b9:
+         00:4a:54:14:d7:70:9e:ac:11:4b:5d:bc:07:fe:2e:d2:9a:99:
+         dc:da:88:3f:98:c0:e1:1a:10:53:66:81:0f:e0:18:4b:a7:83:
+         a5:eb:bb:27:cc:44:95:be:6b:7b:eb:4d:82:1e:a2:52:da:78:
+         f7:b1:1d:7c:ce:91:97:0e:4d:14:34:8c:b3:8d:86:ac:4f:1f:
+         45:d1:bc:eb:40:f7:a7:56:55:7e:45:a2:2b:1c:12:33:6a:7d:
+         b8:7e:fa:1d:79:5a:0c:d4:83:19:22:d9:f5:04:3c:89:28:0b:
+         3e:38:d7:71:93:fa:d9:45:d5:7a:5e:ce:63:47:91:2f:74:c9:
+         50:dd:8d:9f:cf:f0:ab:7e:c6:b2:4f:48:e8:61:71:7a:28:12:
+         ea:f2:bf:4c:e8:7e:f7:6f:44:a8:35:a7:82:47:bd:7a:ae:75:
+         c8:f8:94:7c:42:7a:2e:bf:30:b4:31:31:b3:2e:98:0a:25:a0:
+         57:f7:11:77
+-----BEGIN CERTIFICATE-----
+MIIDpzCCAo+gAwIBAgICEjkwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDE4
+MTYxNloYDzIyODgxMTI1MTgxNjE2WjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMUBm
+b28uYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4169/JKdJtFj
+cLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHggHzQVHEnC8gpn5cHRyKd5E3vMcX+d
+egotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/W5jwT9d1RUckryFtMHI05PaEa+Fy
+dVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW8F+jE7icpA9pyvblgo4u1OegPFlo
+VQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5wuAEA/9WbbGbbs0lS6uruAbnMmnR
+vecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC525MF2jYdU60/bPSZ9CoPrLF/hRa
+BGuaT5BbswIDAQABo4GoMIGlMAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9w
+ZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBTh4y7jea7wZBgt
+7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9njALBgNV
+HQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMA0GCSqGSIb3
+DQEBCwUAA4IBAQDDzHBcIw/YtDV1SH0BbCH7IwazwPzhFFcvk5hXZkgDbEukYbnR
+smkcIxoqne3VPSJhiLn39Dq5i+YCdF9n3oRNc03B4N325rkASlQU13CerBFLXbwH
+/i7Smpnc2og/mMDhGhBTZoEP4BhLp4Ol67snzESVvmt7602CHqJS2nj3sR18zpGX
+Dk0UNIyzjYasTx9F0bzrQPenVlV+RaIrHBIzan24fvodeVoM1IMZItn1BDyJKAs+
+ONdxk/rZRdV6Xs5jR5EvdMlQ3Y2fz/CrfsayT0joYXF6KBLq8r9M6H73b0SoNaeC
+R716rnXI+JR8QnouvzC0MTGzLpgKJaBX9xF3
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_ecc.key b/crypto/default/cms/testdata/user1_ecc.key
new file mode 100644
index 0000000..fc16b97
--- /dev/null
+++ b/crypto/default/cms/testdata/user1_ecc.key
@@ -0,0 +1,8 @@
+-----BEGIN EC PARAMETERS-----
+BggqhkjOPQMBBw==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEII1hkPn+AzrDtXMdbb2Z0F1dZOKiW/ZtxmI2J8ugy8lioAoGCCqGSM49
+AwEHoUQDQgAELeZnmRirlkD1IVa7hRIK6DVio+ztb2UuewxyQHYTKx8Js8GISIqf
+o3DfBCCUnKvQFZo4h63rbA7qa/IwqsVC5g==
+-----END EC PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user1_ecc.pem b/crypto/default/cms/testdata/user1_ecc.pem
new file mode 100644
index 0000000..bbf2120
--- /dev/null
+++ b/crypto/default/cms/testdata/user1_ecc.pem
@@ -0,0 +1,68 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4667 (0x123b)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Feb 28 00:22:18 2015 GMT
+            Not After : Dec 13 00:22:18 2288 GMT
+        Subject: emailAddress=user1@foo.bar
+        Subject Public Key Info:
+            Public Key Algorithm: id-ecPublicKey
+                Public-Key: (256 bit)
+                pub: 
+                    04:2d:e6:67:99:18:ab:96:40:f5:21:56:bb:85:12:
+                    0a:e8:35:62:a3:ec:ed:6f:65:2e:7b:0c:72:40:76:
+                    13:2b:1f:09:b3:c1:88:48:8a:9f:a3:70:df:04:20:
+                    94:9c:ab:d0:15:9a:38:87:ad:eb:6c:0e:ea:6b:f2:
+                    30:aa:c5:42:e6
+                ASN1 OID: prime256v1
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                9B:6E:AE:67:94:5D:61:FB:73:63:71:44:8C:E0:F2:EC:BA:43:CB:A0
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+    Signature Algorithm: sha256WithRSAEncryption
+         a8:7a:ae:b8:a3:ce:00:24:9e:08:11:01:6d:87:31:ef:db:01:
+         4a:e9:23:ab:c1:08:c3:3e:0b:50:e7:c5:0c:57:0a:d8:2e:d5:
+         0a:6c:77:89:4c:24:01:4e:bf:b0:5f:55:d4:92:cc:9c:01:3a:
+         f1:3a:4a:60:3c:e6:63:a7:ed:30:f6:bf:55:63:90:1c:79:09:
+         03:34:14:e6:7a:48:ca:66:b2:b1:51:90:99:e7:0a:e2:62:09:
+         6f:bf:f1:46:bb:b5:ab:3a:6a:f1:ea:09:4b:c9:ee:1f:3e:fd:
+         1d:53:4c:d2:54:3f:98:55:45:df:4e:7e:0d:72:5e:74:86:24:
+         20:57:a7:93:98:cf:7d:8c:ee:67:85:23:72:77:d3:4f:89:77:
+         e0:54:f7:7a:e1:0f:91:33:85:26:e5:bb:25:4a:a4:21:f7:02:
+         68:bd:7b:fa:96:d7:77:24:81:d6:8c:17:63:31:2a:39:db:fd:
+         c9:37:ca:57:66:b6:13:01:a4:59:93:ac:32:7a:03:1f:fe:b3:
+         04:a4:7e:3b:54:68:d5:d5:f3:26:64:b9:24:83:58:85:9a:43:
+         66:1e:a5:ae:17:d5:d3:9a:0c:dc:e3:e6:85:67:7c:74:5e:64:
+         f6:4b:37:33:7e:fd:90:3b:33:7e:2a:46:9f:34:e7:9d:10:b9:
+         6b:0d:4c:87
+-----BEGIN CERTIFICATE-----
+MIIC3DCCAcSgAwIBAgICEjswDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIyODAw
+MjIxOFoYDzIyODgxMjEzMDAyMjE4WjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMUBm
+b28uYmFyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELeZnmRirlkD1IVa7hRIK
+6DVio+ztb2UuewxyQHYTKx8Js8GISIqfo3DfBCCUnKvQFZo4h63rbA7qa/IwqsVC
+5qOBqDCBpTAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVy
+YXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUm26uZ5RdYftzY3FEjODy7LpDy6Aw
+HwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQDAgXgMB0G
+A1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDANBgkqhkiG9w0BAQsFAAOCAQEA
+qHquuKPOACSeCBEBbYcx79sBSukjq8EIwz4LUOfFDFcK2C7VCmx3iUwkAU6/sF9V
+1JLMnAE68TpKYDzmY6ftMPa/VWOQHHkJAzQU5npIymaysVGQmecK4mIJb7/xRru1
+qzpq8eoJS8nuHz79HVNM0lQ/mFVF305+DXJedIYkIFenk5jPfYzuZ4UjcnfTT4l3
+4FT3euEPkTOFJuW7JUqkIfcCaL17+pbXdySB1owXYzEqOdv9yTfKV2a2EwGkWZOs
+MnoDH/6zBKR+O1Ro1dXzJmS5JINYhZpDZh6lrhfV05oM3OPmhWd8dF5k9ks3M379
+kDszfipGnzTnnRC5aw1Mhw==
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_ecdsa.key b/crypto/default/cms/testdata/user1_ecdsa.key
new file mode 100644
index 0000000..4031e9e
--- /dev/null
+++ b/crypto/default/cms/testdata/user1_ecdsa.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9bRcGuN/nDg8xxhv
+HLlXiemA1xCKiken7KBwWNMXuPuhRANCAARKIZfCgUgKB2J3kAcNpjbTB5cEJ4Ti
+d0pIMSEZrW42T7+KAIZwHbfgz34Jw88WmHL1SH4rMvU5QSVrp1UywhEd
+-----END PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user1_ecdsa.pem b/crypto/default/cms/testdata/user1_ecdsa.pem
new file mode 100644
index 0000000..8adce2e
--- /dev/null
+++ b/crypto/default/cms/testdata/user1_ecdsa.pem
@@ -0,0 +1,46 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number:
+            6b:ef:0b:67:72:36:62:c5:4e:99:1d:e3:65:cd:df:84:e2:a4:02:cd
+        Signature Algorithm: ecdsa-with-SHA256
+        Issuer: emailAddress=user1@foo.bar
+        Validity
+            Not Before: Feb  4 18:10:51 2025 GMT
+            Not After : Jan 11 18:10:51 2125 GMT
+        Subject: emailAddress=user1@foo.bar
+        Subject Public Key Info:
+            Public Key Algorithm: id-ecPublicKey
+                Public-Key: (256 bit)
+                pub:
+                    04:4a:21:97:c2:81:48:0a:07:62:77:90:07:0d:a6:
+                    36:d3:07:97:04:27:84:e2:77:4a:48:31:21:19:ad:
+                    6e:36:4f:bf:8a:00:86:70:1d:b7:e0:cf:7e:09:c3:
+                    cf:16:98:72:f5:48:7e:2b:32:f5:39:41:25:6b:a7:
+                    55:32:c2:11:1d
+                ASN1 OID: prime256v1
+                NIST CURVE: P-256
+        X509v3 extensions:
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                E-mail Protection, TLS Web Client Authentication
+            X509v3 Subject Key Identifier: 
+                A7:52:E1:C3:0A:E3:B8:0D:80:75:73:DD:4D:4B:6C:66:92:AE:00:EB
+    Signature Algorithm: ecdsa-with-SHA256
+    Signature Value:
+        30:45:02:20:6d:98:d6:e4:c0:45:45:e5:c1:00:ef:e5:1e:aa:
+        90:93:ab:a9:15:df:de:e0:75:ee:f4:94:3c:0d:ae:1d:9c:dd:
+        02:21:00:90:50:5d:11:16:d9:27:27:64:1b:34:38:f0:26:94:
+        12:bc:e2:76:3d:93:e8:c3:81:a5:23:60:5e:83:a1:02:31
+-----BEGIN CERTIFICATE-----
+MIIBjTCCATOgAwIBAgIUa+8LZ3I2YsVOmR3jZc3fhOKkAs0wCgYIKoZIzj0EAwIw
+HjEcMBoGCSqGSIb3DQEJARYNdXNlcjFAZm9vLmJhcjAgFw0yNTAyMDQxODEwNTFa
+GA8yMTI1MDExMTE4MTA1MVowHjEcMBoGCSqGSIb3DQEJARYNdXNlcjFAZm9vLmJh
+cjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEohl8KBSAoHYneQBw2mNtMHlwQn
+hOJ3SkgxIRmtbjZPv4oAhnAdt+DPfgnDzxaYcvVIfisy9TlBJWunVTLCER2jTTBL
+MAsGA1UdDwQEAwIF4DAdBgNVHSUEFjAUBggrBgEFBQcDBAYIKwYBBQUHAwIwHQYD
+VR0OBBYEFKdS4cMK47gNgHVz3U1LbGaSrgDrMAoGCCqGSM49BAMCA0gAMEUCIG2Y
+1uTARUXlwQDv5R6qkJOrqRXf3uB17vSUPA2uHZzdAiEAkFBdERbZJydkGzQ48CaU
+Erzidj2T6MOBpSNgXoOhAjE=
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user1_no_cn.pem b/crypto/default/cms/testdata/user1_no_cn.pem
new file mode 100644
index 0000000..f6e97a6
--- /dev/null
+++ b/crypto/default/cms/testdata/user1_no_cn.pem
@@ -0,0 +1,87 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4661 (0x1235)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Feb 10 00:28:52 2015 GMT
+            Not After : Nov 25 00:28:52 2288 GMT
+        Subject: 
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+                Public-Key: (2048 bit)
+                Modulus:
+                    00:e3:5e:bd:fc:92:9d:26:d1:63:70:b6:c4:1c:e9:
+                    7a:65:32:e2:d7:98:d1:ba:ea:36:0d:5d:15:19:cb:
+                    3e:18:ce:7d:24:78:20:1f:34:15:1c:49:c2:f2:0a:
+                    67:e5:c1:d1:c8:a7:79:13:7b:cc:71:7f:9d:7a:0a:
+                    2d:34:1f:0e:36:d8:34:1f:cb:df:bb:40:35:62:4e:
+                    f8:b4:5b:8b:a8:a9:34:3b:19:3f:5b:98:f0:4f:d7:
+                    75:45:47:24:af:21:6d:30:72:34:e4:f6:84:6b:e1:
+                    72:75:50:57:ab:e7:ed:f1:54:bc:b1:5b:03:bf:bd:
+                    b7:49:b1:11:8c:22:62:4c:74:3a:8d:b8:d6:f0:5f:
+                    a3:13:b8:9c:a4:0f:69:ca:f6:e5:82:8e:2e:d4:e7:
+                    a0:3c:59:68:55:0a:67:7d:38:88:98:6c:82:d6:3c:
+                    1f:09:c0:f0:55:77:36:82:3c:ed:0d:61:49:79:a8:
+                    79:c2:e0:04:03:ff:56:6d:b1:9b:6e:cd:25:4b:ab:
+                    ab:b8:06:e7:32:69:d1:bd:e7:2f:b0:b5:ac:23:ef:
+                    2c:9a:91:cc:60:b2:41:09:9b:a4:b4:3c:34:d8:b7:
+                    db:c4:80:02:e7:6e:4c:17:68:d8:75:4e:b4:fd:b3:
+                    d2:67:d0:a8:3e:b2:c5:fe:14:5a:04:6b:9a:4f:90:
+                    5b:b3
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                E1:E3:2E:E3:79:AE:F0:64:18:2D:EC:F6:C5:EE:13:3D:73:7C:13:9D
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+            X509v3 Subject Alternative Name: 
+                email:user1@foo.bar
+    Signature Algorithm: sha256WithRSAEncryption
+         6f:f6:21:27:1a:df:8c:5c:e2:78:12:bb:78:f2:be:a0:c1:85:
+         6e:d8:16:17:52:3a:e3:23:97:8d:60:d4:97:da:c8:c7:55:fb:
+         98:92:ff:b1:ac:b0:de:2a:8c:48:a2:83:b7:16:33:cd:98:7d:
+         33:0c:d9:28:4f:81:01:91:1a:f9:38:95:87:12:80:72:58:da:
+         fe:5e:44:b4:99:47:91:10:3d:52:16:94:a3:0c:d1:57:dd:00:
+         f0:91:cd:df:69:26:90:2a:b7:e0:c6:26:b6:0e:3b:da:3c:d9:
+         a0:e9:b2:3d:f4:95:a7:65:f2:c5:72:01:a6:a9:4a:2c:92:c7:
+         dc:c5:ca:56:d6:e4:4b:e9:f2:91:3d:01:df:41:6f:cb:e4:f6:
+         ed:61:94:2d:1a:25:05:51:53:7a:b8:5c:83:04:da:b1:ea:bb:
+         5f:e2:17:d0:fb:4e:7b:4d:24:5e:a4:e8:02:43:fc:21:8e:e3:
+         9b:f1:9c:51:60:b8:c6:39:44:ed:f1:a1:f1:06:42:cb:f2:d3:
+         f3:76:75:ff:a9:85:a7:9a:5c:ce:79:80:79:8a:d2:18:ea:97:
+         08:98:d8:8e:f0:65:5e:75:fb:f5:fc:b3:31:df:e9:b4:46:45:
+         64:68:78:68:aa:0f:df:c7:29:8a:ad:18:02:b9:86:41:9a:e9:
+         23:2a:5f:03
+-----BEGIN CERTIFICATE-----
+MIIDozCCAougAwIBAgICEjUwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAw
+Mjg1MloYDzIyODgxMTI1MDAyODUyWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEA4169/JKdJtFjcLbEHOl6ZTLi15jRuuo2DV0VGcs+GM59JHggHzQV
+HEnC8gpn5cHRyKd5E3vMcX+degotNB8ONtg0H8vfu0A1Yk74tFuLqKk0Oxk/W5jw
+T9d1RUckryFtMHI05PaEa+FydVBXq+ft8VS8sVsDv723SbERjCJiTHQ6jbjW8F+j
+E7icpA9pyvblgo4u1OegPFloVQpnfTiImGyC1jwfCcDwVXc2gjztDWFJeah5wuAE
+A/9WbbGbbs0lS6uruAbnMmnRvecvsLWsI+8smpHMYLJBCZuktDw02LfbxIAC525M
+F2jYdU60/bPSZ9CoPrLF/hRaBGuaT5BbswIDAQABo4HCMIG/MAkGA1UdEwQCMAAw
+LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G
+A1UdDgQWBBTh4y7jea7wZBgt7PbF7hM9c3wTnTAfBgNVHSMEGDAWgBQB+ocKO2tR
+lty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG
+CCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIxQGZvby5iYXIwDQYJKoZIhvcNAQEL
+BQADggEBAG/2ISca34xc4ngSu3jyvqDBhW7YFhdSOuMjl41g1JfayMdV+5iS/7Gs
+sN4qjEiig7cWM82YfTMM2ShPgQGRGvk4lYcSgHJY2v5eRLSZR5EQPVIWlKMM0Vfd
+APCRzd9pJpAqt+DGJrYOO9o82aDpsj30ladl8sVyAaapSiySx9zFylbW5Evp8pE9
+Ad9Bb8vk9u1hlC0aJQVRU3q4XIME2rHqu1/iF9D7TntNJF6k6AJD/CGO45vxnFFg
+uMY5RO3xofEGQsvy0/N2df+phaeaXM55gHmK0hjqlwiY2I7wZV51+/X8szHf6bRG
+RWRoeGiqD9/HKYqtGAK5hkGa6SMqXwM=
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user2.key b/crypto/default/cms/testdata/user2.key
new file mode 100644
index 0000000..87ebb2a
--- /dev/null
+++ b/crypto/default/cms/testdata/user2.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAygTQ+T7qURHYyhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSk
+aTnppXmr6nvzHIxsVZ2FohK2sqHHQtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU
+8F1wu4KhCeNaZ23xfGb4UrqfLxkVViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno
+3iiuNVYGU/YC8NGBU+ZkrB8VllKJyt916wKvN/I7sDgONk68KceP03HNvC0msV2+
+j0f/20olmx/wvzJAbrObOzQ/XDM730onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/a
+Yv45TmNZMFE4dPBMYaWErJpk88OSj9RP80GDPwIDAQABAoIBAQCqCnHHSdC62jXK
+r721uq9Xq+RC1guRFfGvc42F1Q3EJTeGvQTyWT+z+/zbiucv4XaSfdtsdyn2o7tw
+JQisukAv/DJeve0GQfGUcNkmi0j16hdgiIQYKd5oxmQ8SXTf2CfKWXWtVM4GO6u8
+7Ila9TB0BHuV0Vmb2I3jrLVQuInK+oMdoOb/UMiMJs6FYxrhbiO4ded18w04cD4G
++0IhJqogVNm19F2ls25YGkk/Dhk0fCofPvBYCbS75vntrPzkIhq30/PrHpWTHs3m
+c8k4acwkWFhjpqGxtlktBUVROIUJw7WrK4j4aOxXbT+9n9PqLXcG7OCPWWlYFEwr
+XnhXwUSRAoGBAOovNkLFvkKOe/EcINHTt3wR/DnvSvnxvi1u9JttROAY1BtiHGlV
+rmz2pXouMfeoV3b9RCmnlHuBFiPBYx/KyDqVSwbNfxXEAQQAseNIgg3xoz9EApGO
+fJ2TXl+RNCLHbIwu3B65wog1qh1UmnhmT6qpqYNcL9HFAPIgFInhWQHJAoGBANzW
+hlLaPBjFrKRK9Y9kvc/blRVBjYVPutN95VznoZ/PBumqUyZXX9prx8XrdVMmFKGX
+9mgHWxjYeJ4TO5Oq+nC6HM+OeVW2FMNWiZnvr3AjuNev5+gubQWi4xQFKoOS5w7u
+SrQ7PgSZ8sUDR1OzAweLjcxJkj7vo0uqK0icGCDHAoGBALRZTZlYvjB1mJ/TwLa3
+LLxBAZUAYyZf9Mz6gShdm0hV9qtsAGNil+TUghW5E8QdgdZiL6dApooCnLFGknb+
+bPso1FHV2kRB4WJawqQ9x+LDL6xeTPgZ2oBj6K8CnTsXh9HhmKWU+2p4oZza9ec5
+K6tL4/8E9NLAu/y7lfWldS15AoGAGT0jSODMMYCWBIjUm7jFwLW1yiIdzHiaP4WJ
+6x5F6H++qIHg0ub1e38J6+LHG+OkQ/ucG7OLg8Fpt5c3Hd9SlAORO19rGpKeuNxx
+CXjNMiHDBFOES5LSvuwoy6bzfuoRIjG1s/d5VQlLJ50CT9yLohce85QM9Mhqf0FJ
+hRjh4Q0CgYBnR2JyTu1C8ziFIddw0nIlAYycjzlHD68xOxi/0LWk7jGQnvRQbvNd
+UBGtJdcPPpNRnGtWkZGU9+ywffFLjfdFc3IBKBfQ+dgnGxGOmRux5I6SPFtI3axU
+RQw+WNaETshLiyrzyjEDUvR//rbSRX9x5Ae7R9rJmEWhhZzTz2+JIg==
+-----END RSA PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user2_both.pem b/crypto/default/cms/testdata/user2_both.pem
new file mode 100644
index 0000000..d50bada
--- /dev/null
+++ b/crypto/default/cms/testdata/user2_both.pem
@@ -0,0 +1,88 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4663 (0x1237)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Feb 10 18:14:13 2015 GMT
+            Not After : Nov 25 18:14:13 2288 GMT
+        Subject: emailAddress=user2@foo.bar
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+                Public-Key: (2048 bit)
+                Modulus:
+                    00:ca:04:d0:f9:3e:ea:51:11:d8:ca:1a:b4:77:f9:
+                    bf:ca:aa:1a:6f:f4:b4:c8:5b:f9:8b:e7:04:bc:42:
+                    d3:56:83:78:dc:24:a4:69:39:e9:a5:79:ab:ea:7b:
+                    f3:1c:8c:6c:55:9d:85:a2:12:b6:b2:a1:c7:42:d7:
+                    f0:99:ca:75:73:43:df:8b:f1:c7:8f:3e:16:a1:5a:
+                    60:a5:74:9d:bc:9e:de:9c:c6:54:f0:5d:70:bb:82:
+                    a1:09:e3:5a:67:6d:f1:7c:66:f8:52:ba:9f:2f:19:
+                    15:56:23:96:3e:48:2e:1d:d3:79:72:3f:28:94:7a:
+                    6e:a0:d5:84:a5:25:83:8f:41:74:44:b9:e8:de:28:
+                    ae:35:56:06:53:f6:02:f0:d1:81:53:e6:64:ac:1f:
+                    15:96:52:89:ca:df:75:eb:02:af:37:f2:3b:b0:38:
+                    0e:36:4e:bc:29:c7:8f:d3:71:cd:bc:2d:26:b1:5d:
+                    be:8f:47:ff:db:4a:25:9b:1f:f0:bf:32:40:6e:b3:
+                    9b:3b:34:3f:5c:33:3b:df:4a:27:0b:aa:3e:6d:25:
+                    af:36:67:96:97:61:fc:97:35:61:e3:c2:41:9f:02:
+                    56:9f:df:da:62:fe:39:4e:63:59:30:51:38:74:f0:
+                    4c:61:a5:84:ac:9a:64:f3:c3:92:8f:d4:4f:f3:41:
+                    83:3f
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                21:44:1A:4A:7B:99:73:C9:7B:3E:C6:E7:4C:C0:4D:D1:D6:1F:DA:E0
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+            X509v3 Subject Alternative Name: 
+                email:user2@foo.bar
+    Signature Algorithm: sha256WithRSAEncryption
+         06:35:8f:bb:63:7a:5e:c6:0e:95:ed:f3:4b:13:fc:d1:af:3b:
+         cd:3a:23:2e:02:df:5d:b7:b1:2a:90:a6:4e:84:09:85:70:d3:
+         f2:86:02:52:66:0e:66:e0:1a:be:fe:72:5f:18:2d:8f:57:ed:
+         ff:e8:2b:5b:28:b2:28:b8:94:05:b1:01:5e:9c:90:8f:6a:b8:
+         36:bb:20:49:b7:dd:cf:95:1d:ac:f0:e2:2a:7b:4e:f3:bb:89:
+         ae:49:6c:00:34:5c:09:2e:20:9e:c0:d2:81:07:64:89:ff:e5:
+         7b:eb:96:12:68:fe:19:96:44:69:e3:95:10:c1:c5:2e:3e:07:
+         70:ef:1e:d1:b1:02:af:9e:d8:23:46:39:62:f2:72:6a:4b:f4:
+         9d:d9:0d:4d:7f:f9:9d:a0:0a:0e:53:26:7d:c5:5d:d8:30:36:
+         06:05:5c:9e:19:6e:b2:8a:5f:b1:4f:3e:31:cb:9a:a8:6b:89:
+         52:a8:38:ee:37:13:99:de:a4:0d:f2:7e:3e:ed:a5:28:9b:99:
+         1d:8d:bd:fb:dd:e8:59:6b:5d:04:35:99:04:93:ed:76:ce:3c:
+         8a:ab:67:9b:49:9f:d0:bd:94:89:58:03:5b:e8:ba:61:cc:78:
+         d2:82:a2:48:2f:f5:ad:3e:ac:9a:b6:d3:64:ac:f7:11:8b:f8:
+         f2:d0:e9:37
+-----BEGIN CERTIFICATE-----
+MIIDwTCCAqmgAwIBAgICEjcwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDE4
+MTQxM1oYDzIyODgxMTI1MTgxNDEzWjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMkBm
+b28uYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAygTQ+T7qURHY
+yhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSkaTnppXmr6nvzHIxsVZ2FohK2sqHH
+Qtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU8F1wu4KhCeNaZ23xfGb4UrqfLxkV
+ViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno3iiuNVYGU/YC8NGBU+ZkrB8VllKJ
+yt916wKvN/I7sDgONk68KceP03HNvC0msV2+j0f/20olmx/wvzJAbrObOzQ/XDM7
+30onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/aYv45TmNZMFE4dPBMYaWErJpk88OS
+j9RP80GDPwIDAQABo4HCMIG/MAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9w
+ZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBQhRBpKe5lzyXs+
+xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9njALBgNV
+HQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMBgGA1UdEQQR
+MA+BDXVzZXIyQGZvby5iYXIwDQYJKoZIhvcNAQELBQADggEBAAY1j7tjel7GDpXt
+80sT/NGvO806Iy4C3123sSqQpk6ECYVw0/KGAlJmDmbgGr7+cl8YLY9X7f/oK1so
+sii4lAWxAV6ckI9quDa7IEm33c+VHazw4ip7TvO7ia5JbAA0XAkuIJ7A0oEHZIn/
+5XvrlhJo/hmWRGnjlRDBxS4+B3DvHtGxAq+e2CNGOWLycmpL9J3ZDU1/+Z2gCg5T
+Jn3FXdgwNgYFXJ4ZbrKKX7FPPjHLmqhriVKoOO43E5nepA3yfj7tpSibmR2Nvfvd
+6FlrXQQ1mQST7XbOPIqrZ5tJn9C9lIlYA1voumHMeNKCokgv9a0+rJq202Ss9xGL
++PLQ6Tc=
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user2_cn_only.pem b/crypto/default/cms/testdata/user2_cn_only.pem
new file mode 100644
index 0000000..3a284fd
--- /dev/null
+++ b/crypto/default/cms/testdata/user2_cn_only.pem
@@ -0,0 +1,85 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4666 (0x123a)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Feb 10 18:16:29 2015 GMT
+            Not After : Nov 25 18:16:29 2288 GMT
+        Subject: emailAddress=user2@foo.bar
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+                Public-Key: (2048 bit)
+                Modulus:
+                    00:ca:04:d0:f9:3e:ea:51:11:d8:ca:1a:b4:77:f9:
+                    bf:ca:aa:1a:6f:f4:b4:c8:5b:f9:8b:e7:04:bc:42:
+                    d3:56:83:78:dc:24:a4:69:39:e9:a5:79:ab:ea:7b:
+                    f3:1c:8c:6c:55:9d:85:a2:12:b6:b2:a1:c7:42:d7:
+                    f0:99:ca:75:73:43:df:8b:f1:c7:8f:3e:16:a1:5a:
+                    60:a5:74:9d:bc:9e:de:9c:c6:54:f0:5d:70:bb:82:
+                    a1:09:e3:5a:67:6d:f1:7c:66:f8:52:ba:9f:2f:19:
+                    15:56:23:96:3e:48:2e:1d:d3:79:72:3f:28:94:7a:
+                    6e:a0:d5:84:a5:25:83:8f:41:74:44:b9:e8:de:28:
+                    ae:35:56:06:53:f6:02:f0:d1:81:53:e6:64:ac:1f:
+                    15:96:52:89:ca:df:75:eb:02:af:37:f2:3b:b0:38:
+                    0e:36:4e:bc:29:c7:8f:d3:71:cd:bc:2d:26:b1:5d:
+                    be:8f:47:ff:db:4a:25:9b:1f:f0:bf:32:40:6e:b3:
+                    9b:3b:34:3f:5c:33:3b:df:4a:27:0b:aa:3e:6d:25:
+                    af:36:67:96:97:61:fc:97:35:61:e3:c2:41:9f:02:
+                    56:9f:df:da:62:fe:39:4e:63:59:30:51:38:74:f0:
+                    4c:61:a5:84:ac:9a:64:f3:c3:92:8f:d4:4f:f3:41:
+                    83:3f
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                21:44:1A:4A:7B:99:73:C9:7B:3E:C6:E7:4C:C0:4D:D1:D6:1F:DA:E0
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+    Signature Algorithm: sha256WithRSAEncryption
+         92:ef:53:62:8d:21:66:0c:50:da:21:cc:db:f0:a7:a1:c8:af:
+         2a:28:de:17:cb:91:98:b6:14:f7:fa:fa:85:a1:b4:41:cf:e4:
+         e1:73:75:79:43:2b:24:6c:7a:5b:63:d7:a3:3f:80:b9:3d:25:
+         8e:6d:65:c4:b1:72:5a:93:3e:8b:94:b0:d2:f1:01:30:b0:64:
+         c1:23:28:f0:a9:e9:d1:3c:f0:d1:4e:af:98:1a:59:1b:ec:ac:
+         05:49:d0:b0:ce:6f:35:cf:3b:56:aa:32:05:d4:8d:af:c3:ff:
+         ef:ee:c8:6e:af:84:6b:c6:4b:64:87:a8:89:ac:d1:5a:3d:7b:
+         83:93:66:96:3d:68:64:20:6b:31:7f:31:a2:80:4b:fb:ff:66:
+         d5:00:b9:0d:67:8a:2a:a2:e9:56:92:35:c8:00:ff:6a:86:3b:
+         75:41:f5:bd:08:4a:c4:ff:25:0b:c1:94:a7:b0:0b:23:a3:bb:
+         00:39:fa:42:04:88:09:b3:7f:4b:ad:92:f6:36:68:24:31:80:
+         b6:07:95:de:90:ca:98:47:b3:3a:79:c5:8a:2d:6d:9d:c0:54:
+         f2:a2:02:62:8d:a5:34:95:82:36:65:94:da:35:a8:64:76:09:
+         12:de:93:9d:cd:6d:1d:af:af:d7:d8:6a:2a:41:c2:39:29:c4:
+         d4:f0:5e:dd
+-----BEGIN CERTIFICATE-----
+MIIDpzCCAo+gAwIBAgICEjowDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDE4
+MTYyOVoYDzIyODgxMTI1MTgxNjI5WjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMkBm
+b28uYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAygTQ+T7qURHY
+yhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSkaTnppXmr6nvzHIxsVZ2FohK2sqHH
+Qtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU8F1wu4KhCeNaZ23xfGb4UrqfLxkV
+ViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno3iiuNVYGU/YC8NGBU+ZkrB8VllKJ
+yt916wKvN/I7sDgONk68KceP03HNvC0msV2+j0f/20olmx/wvzJAbrObOzQ/XDM7
+30onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/aYv45TmNZMFE4dPBMYaWErJpk88OS
+j9RP80GDPwIDAQABo4GoMIGlMAkGA1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9w
+ZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBQhRBpKe5lzyXs+
+xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocKO2tRlty2pKcfSHPfShj9njALBgNV
+HQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMA0GCSqGSIb3
+DQEBCwUAA4IBAQCS71NijSFmDFDaIczb8KehyK8qKN4Xy5GYthT3+vqFobRBz+Th
+c3V5QyskbHpbY9ejP4C5PSWObWXEsXJakz6LlLDS8QEwsGTBIyjwqenRPPDRTq+Y
+Glkb7KwFSdCwzm81zztWqjIF1I2vw//v7shur4Rrxktkh6iJrNFaPXuDk2aWPWhk
+IGsxfzGigEv7/2bVALkNZ4oqoulWkjXIAP9qhjt1QfW9CErE/yULwZSnsAsjo7sA
+OfpCBIgJs39LrZL2NmgkMYC2B5XekMqYR7M6ecWKLW2dwFTyogJijaU0lYI2ZZTa
+NahkdgkS3pOdzW0dr6/X2GoqQcI5KcTU8F7d
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user2_ecc.key b/crypto/default/cms/testdata/user2_ecc.key
new file mode 100644
index 0000000..d09dfe1
--- /dev/null
+++ b/crypto/default/cms/testdata/user2_ecc.key
@@ -0,0 +1,8 @@
+-----BEGIN EC PARAMETERS-----
+BggqhkjOPQMBBw==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIJPRuV2jcPLfORpncLK4uD8R98vMzztQSRlpMAHjanqAoAoGCCqGSM49
+AwEHoUQDQgAEPcc9MtQC+axgNFfX3kwGJR4WcKQvYeAZuRVV4I9n1spqEqsl3lDG
+sjeSBOQkJTPEUfPG1VKe3/1wUzHikzaqfw==
+-----END EC PRIVATE KEY-----
diff --git a/crypto/default/cms/testdata/user2_ecc.pem b/crypto/default/cms/testdata/user2_ecc.pem
new file mode 100644
index 0000000..77ac5ac
--- /dev/null
+++ b/crypto/default/cms/testdata/user2_ecc.pem
@@ -0,0 +1,68 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4668 (0x123c)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Mar  4 02:00:42 2015 GMT
+            Not After : Dec 17 02:00:42 2288 GMT
+        Subject: emailAddress=user2@foo.bar
+        Subject Public Key Info:
+            Public Key Algorithm: id-ecPublicKey
+                Public-Key: (256 bit)
+                pub: 
+                    04:3d:c7:3d:32:d4:02:f9:ac:60:34:57:d7:de:4c:
+                    06:25:1e:16:70:a4:2f:61:e0:19:b9:15:55:e0:8f:
+                    67:d6:ca:6a:12:ab:25:de:50:c6:b2:37:92:04:e4:
+                    24:25:33:c4:51:f3:c6:d5:52:9e:df:fd:70:53:31:
+                    e2:93:36:aa:7f
+                ASN1 OID: prime256v1
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                78:59:B0:BC:C0:8D:22:79:2B:75:24:87:BE:EC:DB:00:10:96:7E:0F
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+    Signature Algorithm: sha256WithRSAEncryption
+         6c:f1:10:48:4c:bc:4d:03:3e:af:73:de:63:f5:ec:7b:bb:00:
+         9b:fb:02:f2:23:7e:d8:5f:cd:e2:db:99:ac:fb:18:45:76:8f:
+         1c:53:ce:ca:4e:e8:75:30:ee:75:ad:0b:38:1a:a7:c2:3f:1e:
+         64:83:de:5c:e0:b0:0b:a3:a2:75:4c:c0:74:2e:e8:88:aa:e2:
+         be:49:42:28:1e:9c:68:7a:98:71:40:34:97:f0:7a:92:35:4d:
+         dd:89:6b:04:08:51:17:90:49:b3:b6:00:5a:21:3e:42:5d:71:
+         4a:a1:8e:68:10:3f:96:af:a4:ed:9e:d7:63:e4:24:ea:24:0e:
+         8d:df:72:a8:32:c8:de:ed:4f:63:9b:98:53:4b:44:be:b4:0b:
+         70:65:6d:b6:53:1e:0b:04:fe:80:ef:46:12:4a:8b:f6:10:bb:
+         1c:0b:b4:13:44:75:35:61:ba:f0:08:a0:d2:46:0d:ab:19:7c:
+         9e:bc:bf:e6:9e:96:54:bb:c1:57:ee:64:22:4f:d0:7a:39:71:
+         85:1c:50:bf:7e:28:25:5e:5b:17:1b:4a:48:1b:31:91:99:70:
+         b7:cf:b8:02:86:df:9d:67:fb:92:84:0b:ca:79:61:f0:9c:b6:
+         3e:38:77:79:f2:d3:8e:09:7b:51:e0:ab:c0:dd:b3:da:75:ee:
+         40:d9:a9:77
+-----BEGIN CERTIFICATE-----
+MIIC3DCCAcSgAwIBAgICEjwwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDMwNDAy
+MDA0MloYDzIyODgxMjE3MDIwMDQyWjAeMRwwGgYJKoZIhvcNAQkBFg11c2VyMkBm
+b28uYmFyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPcc9MtQC+axgNFfX3kwG
+JR4WcKQvYeAZuRVV4I9n1spqEqsl3lDGsjeSBOQkJTPEUfPG1VKe3/1wUzHikzaq
+f6OBqDCBpTAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVy
+YXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUeFmwvMCNInkrdSSHvuzbABCWfg8w
+HwYDVR0jBBgwFoAUAfqHCjtrUZbctqSnH0hz30oY/Z4wCwYDVR0PBAQDAgXgMB0G
+A1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDANBgkqhkiG9w0BAQsFAAOCAQEA
+bPEQSEy8TQM+r3PeY/Xse7sAm/sC8iN+2F/N4tuZrPsYRXaPHFPOyk7odTDuda0L
+OBqnwj8eZIPeXOCwC6OidUzAdC7oiKrivklCKB6caHqYcUA0l/B6kjVN3YlrBAhR
+F5BJs7YAWiE+Ql1xSqGOaBA/lq+k7Z7XY+Qk6iQOjd9yqDLI3u1PY5uYU0tEvrQL
+cGVttlMeCwT+gO9GEkqL9hC7HAu0E0R1NWG68Aig0kYNqxl8nry/5p6WVLvBV+5k
+Ik/QejlxhRxQv34oJV5bFxtKSBsxkZlwt8+4AobfnWf7koQLynlh8Jy2Pjh3efLT
+jgl7UeCrwN2z2nXuQNmpdw==
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/testdata/user2_no_cn.pem b/crypto/default/cms/testdata/user2_no_cn.pem
new file mode 100644
index 0000000..d1d665d
--- /dev/null
+++ b/crypto/default/cms/testdata/user2_no_cn.pem
@@ -0,0 +1,87 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 4662 (0x1236)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: C=US, ST=CA, L=Mountain View, O=Smime Test CA, CN=Smime Test CA
+        Validity
+            Not Before: Feb 10 00:30:03 2015 GMT
+            Not After : Nov 25 00:30:03 2288 GMT
+        Subject: 
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+                Public-Key: (2048 bit)
+                Modulus:
+                    00:ca:04:d0:f9:3e:ea:51:11:d8:ca:1a:b4:77:f9:
+                    bf:ca:aa:1a:6f:f4:b4:c8:5b:f9:8b:e7:04:bc:42:
+                    d3:56:83:78:dc:24:a4:69:39:e9:a5:79:ab:ea:7b:
+                    f3:1c:8c:6c:55:9d:85:a2:12:b6:b2:a1:c7:42:d7:
+                    f0:99:ca:75:73:43:df:8b:f1:c7:8f:3e:16:a1:5a:
+                    60:a5:74:9d:bc:9e:de:9c:c6:54:f0:5d:70:bb:82:
+                    a1:09:e3:5a:67:6d:f1:7c:66:f8:52:ba:9f:2f:19:
+                    15:56:23:96:3e:48:2e:1d:d3:79:72:3f:28:94:7a:
+                    6e:a0:d5:84:a5:25:83:8f:41:74:44:b9:e8:de:28:
+                    ae:35:56:06:53:f6:02:f0:d1:81:53:e6:64:ac:1f:
+                    15:96:52:89:ca:df:75:eb:02:af:37:f2:3b:b0:38:
+                    0e:36:4e:bc:29:c7:8f:d3:71:cd:bc:2d:26:b1:5d:
+                    be:8f:47:ff:db:4a:25:9b:1f:f0:bf:32:40:6e:b3:
+                    9b:3b:34:3f:5c:33:3b:df:4a:27:0b:aa:3e:6d:25:
+                    af:36:67:96:97:61:fc:97:35:61:e3:c2:41:9f:02:
+                    56:9f:df:da:62:fe:39:4e:63:59:30:51:38:74:f0:
+                    4c:61:a5:84:ac:9a:64:f3:c3:92:8f:d4:4f:f3:41:
+                    83:3f
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            Netscape Comment: 
+                OpenSSL Generated Certificate
+            X509v3 Subject Key Identifier: 
+                21:44:1A:4A:7B:99:73:C9:7B:3E:C6:E7:4C:C0:4D:D1:D6:1F:DA:E0
+            X509v3 Authority Key Identifier: 
+                keyid:01:FA:87:0A:3B:6B:51:96:DC:B6:A4:A7:1F:48:73:DF:4A:18:FD:9E
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Client Authentication, E-mail Protection
+            X509v3 Subject Alternative Name: 
+                email:user2@foo.bar
+    Signature Algorithm: sha256WithRSAEncryption
+         4b:89:7d:a5:bb:46:ad:ea:ca:98:51:dc:15:67:67:56:82:a2:
+         51:e4:40:b7:04:b1:d7:73:6d:fe:7b:19:62:63:ad:56:f0:bd:
+         5d:54:21:e3:00:7f:41:f6:50:2b:1c:fb:c1:8b:61:c5:c2:bf:
+         2d:f8:22:e9:6d:73:11:8b:ee:0e:a7:ef:05:a7:7c:25:7f:95:
+         8d:09:77:97:91:41:28:ad:d0:11:b2:81:07:62:6f:b7:13:db:
+         42:49:cb:fd:de:b7:65:27:7a:45:47:9c:98:6b:12:fe:42:8a:
+         0c:1a:3a:cf:47:90:82:fd:0b:75:c9:1e:a6:18:1f:21:cb:e9:
+         04:cb:16:9e:49:9f:1a:95:e1:85:94:86:ad:7a:25:f9:39:9e:
+         8e:c1:03:a4:9f:86:99:f6:73:29:f8:ac:6a:07:50:a9:85:05:
+         e3:7d:55:e3:e9:57:66:8a:45:9d:f5:1e:a1:2d:48:72:99:9b:
+         16:57:03:36:d8:4a:b2:17:61:10:6c:31:56:9b:f9:39:f0:36:
+         48:90:5c:06:43:ef:72:5b:6e:86:e5:3a:25:e0:35:46:f1:7b:
+         6f:da:2d:c4:4d:5b:bb:98:e3:60:d1:53:54:06:47:d5:b0:79:
+         d2:a0:bf:d6:51:44:39:9d:de:f4:92:ad:d3:f9:5f:d0:59:34:
+         33:4e:86:d7
+-----BEGIN CERTIFICATE-----
+MIIDozCCAougAwIBAgICEjYwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBWaWV3MRYwFAYDVQQKDA1T
+bWltZSBUZXN0IENBMRYwFAYDVQQDDA1TbWltZSBUZXN0IENBMCAXDTE1MDIxMDAw
+MzAwM1oYDzIyODgxMTI1MDAzMDAzWjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAygTQ+T7qURHYyhq0d/m/yqoab/S0yFv5i+cEvELTVoN43CSkaTnp
+pXmr6nvzHIxsVZ2FohK2sqHHQtfwmcp1c0Pfi/HHjz4WoVpgpXSdvJ7enMZU8F1w
+u4KhCeNaZ23xfGb4UrqfLxkVViOWPkguHdN5cj8olHpuoNWEpSWDj0F0RLno3iiu
+NVYGU/YC8NGBU+ZkrB8VllKJyt916wKvN/I7sDgONk68KceP03HNvC0msV2+j0f/
+20olmx/wvzJAbrObOzQ/XDM730onC6o+bSWvNmeWl2H8lzVh48JBnwJWn9/aYv45
+TmNZMFE4dPBMYaWErJpk88OSj9RP80GDPwIDAQABo4HCMIG/MAkGA1UdEwQCMAAw
+LAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G
+A1UdDgQWBBQhRBpKe5lzyXs+xudMwE3R1h/a4DAfBgNVHSMEGDAWgBQB+ocKO2tR
+lty2pKcfSHPfShj9njALBgNVHQ8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIG
+CCsGAQUFBwMEMBgGA1UdEQQRMA+BDXVzZXIyQGZvby5iYXIwDQYJKoZIhvcNAQEL
+BQADggEBAEuJfaW7Rq3qyphR3BVnZ1aColHkQLcEsddzbf57GWJjrVbwvV1UIeMA
+f0H2UCsc+8GLYcXCvy34IultcxGL7g6n7wWnfCV/lY0Jd5eRQSit0BGygQdib7cT
+20JJy/3et2UnekVHnJhrEv5CigwaOs9HkIL9C3XJHqYYHyHL6QTLFp5JnxqV4YWU
+hq16Jfk5no7BA6Sfhpn2cyn4rGoHUKmFBeN9VePpV2aKRZ31HqEtSHKZmxZXAzbY
+SrIXYRBsMVab+TnwNkiQXAZD73JbboblOiXgNUbxe2/aLcRNW7uY42DRU1QGR9Ww
+edKgv9ZRRDmd3vSSrdP5X9BZNDNOhtc=
+-----END CERTIFICATE-----
diff --git a/crypto/default/cms/verify_signature.cc b/crypto/default/cms/verify_signature.cc
new file mode 100644
index 0000000..1a01172
--- /dev/null
+++ b/crypto/default/cms/verify_signature.cc
@@ -0,0 +1,335 @@
+// 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 "crypto/default/cms/verify_signature.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "crypto/default/cms/cms_parser.h"
+#include "crypto/default/cms/oids.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+#include "openssl/crypto.h"
+#include "openssl/digest.h"
+#include "openssl/err.h"
+#include "openssl/evp.h"
+#include "openssl/mem.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/obj_mac.h"
+#include "openssl/objects.h"
+#include "openssl/rsa.h"
+#include "openssl/x509.h"
+
+using absl::StatusCode;
+
+namespace credentio_cms {
+namespace {
+// Append the OpenSSL error strings to the status message.
+absl::Status OpenSslError(absl::StatusCode code,
+                          absl::string_view error_message) {
+  std::string message(error_message);
+  const char *file, *data;
+  int line, flags;
+  while (uint32_t err = ERR_get_error_line_data(&file, &line, &data, &flags)) {
+    if (file) {
+      absl::StrAppendFormat(&message, "\n%s:%d ", file, line);
+    } else {
+      absl::StrAppend(&message, "\n");
+    }
+
+    if (const char* reason = ERR_reason_error_string(err)) {
+      absl::StrAppend(&message, reason);
+    } else {
+      absl::StrAppend(&message, err);
+    }
+
+    if (data && (flags & ERR_TXT_STRING)) {
+      absl::StrAppend(&message, " - ", data);
+    }
+  }
+  return absl::Status(code, message);
+}
+
+// For PSS signatures, verify the parameters and apply them to 'pkey_ctx'.
+// - 'certificate' is needed for the extra checks needed when a PSS
+// certificate is used.
+// - 'md' is the message digest algorithm used on the content (or signed
+//   attributes), it will be used to initialize the masking function.
+absl::Status SetPssParameters(const SignerInfo& signer, const X509& certificate,
+                              int content_digest_nid, const EVP_MD* md,
+                              EVP_PKEY_CTX* pkey_ctx) {
+  // See https://tools.ietf.org/html/rfc4056 for the checks performed.
+  const unsigned char* p =
+      CBS_data(signer.signature_algorithm.parameter.cbs_ptr());
+  if (p == nullptr ||
+      CBS_len(signer.signature_algorithm.parameter.cbs_ptr()) == 0) {
+    return OpenSslError(absl::StatusCode::kInvalidArgument,
+                        "Missing PSS parameters");
+  }
+  bssl::UniquePtr<RSA_PSS_PARAMS> pss_params(d2i_RSA_PSS_PARAMS(
+      nullptr, &p, CBS_len(&(signer.signature_algorithm.parameter).cbs())));
+  if (pss_params == nullptr) {
+    // http://tools.ietf.org/html/rfc4056#section-2.2
+    return OpenSslError(absl::StatusCode::kInvalidArgument,
+                        "Missing PSS parameters");
+  }
+  if (pss_params->trailerField &&
+      ASN1_INTEGER_get(pss_params->trailerField) != 1) {
+    return OpenSslError(absl::StatusCode::kUnimplemented,
+                        "Unsupported PSS trailer value.");
+  }
+  if (X509_get_signature_nid(&certificate) == NID_rsassaPss) {
+    return OpenSslError(
+        absl::StatusCode::kUnimplemented,
+        "PSS signatures are not supported with RSASSA-PSS certificates.");
+  }
+  if (EVP_PKEY_CTX_set_rsa_padding(pkey_ctx, RSA_PKCS1_PSS_PADDING) != 1) {
+    return OpenSslError(absl::StatusCode::kInternal,
+                        "Failed to set PSS padding");
+  }
+  if (pss_params->hashAlgorithm) {
+    // http://tools.ietf.org/html/rfc4056#section-3
+    const ASN1_OBJECT* algorithm;
+    X509_ALGOR_get0(&algorithm, /*out_param_type=*/nullptr,
+                    /*out_param_value=*/nullptr, pss_params->hashAlgorithm);
+    if (OBJ_obj2nid(algorithm) != content_digest_nid) {
+      return absl::Status(
+          absl::StatusCode::kInvalidArgument,
+          "The PSS Hash algorithm doesn't match the content hash algorithm.");
+    }
+  }
+  if (pss_params->maskGenAlgorithm) {
+    const ASN1_OBJECT* algorithm;
+    X509_ALGOR_get0(&algorithm, /*out_param_type=*/nullptr,
+                    /*out_param_value=*/nullptr, pss_params->maskGenAlgorithm);
+    if (OBJ_obj2nid(algorithm) != NID_mgf1) {
+      return OpenSslError(absl::StatusCode::kUnimplemented,
+                          "Unsupported PSS mask generation algorithm.");
+    }
+  }
+  if (EVP_PKEY_CTX_set_rsa_mgf1_md(pkey_ctx, md) != 1) {
+    return OpenSslError(absl::StatusCode::kInvalidArgument,
+                        "Failed to set the PSS MGF1 hash");
+  }
+  int salt_length = 20;
+  if (pss_params->saltLength != nullptr) {
+    salt_length = ASN1_INTEGER_get(pss_params->saltLength);
+  }
+  if (EVP_PKEY_CTX_set_rsa_pss_saltlen(pkey_ctx, salt_length) != 1) {
+    return OpenSslError(absl::StatusCode::kInvalidArgument,
+                        "Invalid PSS parameters");
+  }
+  return absl::OkStatus();
+}
+
+// Check that the hash of the 'contents' matches the digest present in the
+// authenticated attributes.
+absl::Status VerifySignedAttributesHash(const SignerInfo& signer,
+                                        const std::vector<ByteString>& contents,
+                                        int digest_nid) {
+  const EVP_MD* md = EVP_get_digestbynid(digest_nid);
+  if (md == nullptr) {
+    return OpenSslError(absl::StatusCode::kUnimplemented,
+                        "Failed to get the content hashing EVP_MD object.");
+  }
+  // When the authenticated attributes are present the signature is computed
+  // over them so we need two checks:
+  //   1) That the digest of the contents matches the attribute's
+  //       message_digest.
+  //   2) That the signature of the authenticated attributes is valid.
+  bssl::ScopedEVP_MD_CTX md_context;
+  if (!EVP_DigestInit_ex(md_context.get(), md, nullptr)) {
+    return OpenSslError(absl::StatusCode::kInternal,
+                        "EVP_DigestInit_ex failed.");
+  }
+  for (const auto& chunk : contents) {
+    if (!EVP_DigestUpdate(md_context.get(), CBS_data(&(chunk).cbs()),
+                          CBS_len(chunk.cbs_ptr()))) {
+      return OpenSslError(absl::StatusCode::kInternal,
+                          "EVP_DigestUpdate failed.");
+    }
+  }
+  uint8_t md_value[EVP_MAX_MD_SIZE];
+  unsigned md_len;
+  if (!EVP_DigestFinal_ex(md_context.get(), md_value, &md_len)) {
+    return OpenSslError(absl::StatusCode::kInternal,
+                        "EVP_DigestFinal_ex failed.");
+  }
+  if (CBS_len(&(signer.message_digest).cbs()) != md_len ||
+      CRYPTO_memcmp(md_value, CBS_data(&(signer.message_digest).cbs()),
+                    CBS_len(&(signer.message_digest).cbs())) != 0) {
+    return OpenSslError(absl::StatusCode::kInvalidArgument,
+                        "Hash attribute mismatch");
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<SignatureInfo> VerifySignature(
+    const Content& cms_content, const SignerInfo& signer,
+    const std::vector<ByteString>& contents, const X509& certificate) {
+  ERR_clear_error();  // Clear OpenSSL's error queue for this thread.
+  // Signed attributes checks, see
+  // https://tools.ietf.org/html/rfc5652#section-5.6
+  if (!CompareOid(cms_content.content_type.cbs(), kDataOid, sizeof(kDataOid)) &&
+      CBS_len(signer.raw_signed_attributes.cbs_ptr()) == 0) {
+    return absl::Status(absl::StatusCode::kInvalidArgument,
+                        "The signed attributes are needed when the "
+                        "encapsultated content type is not id-data.");
+  }
+  if (CBS_len(signer.raw_signed_attributes.cbs_ptr()) > 0) {
+    if (CBS_len(signer.content_type_signed.cbs_ptr()) == 0 ||
+        !CompareOid(signer.content_type_signed.cbs(),
+                    cms_content.content_type.cbs())) {
+      return absl::Status(absl::StatusCode::kInvalidArgument,
+                          "Mismatch between the signed attributes content type "
+                          "and the encapsultated content type.");
+    }
+    if (CBS_len(signer.message_digest.cbs_ptr()) == 0) {
+      return absl::Status(absl::StatusCode::kInvalidArgument,
+                          "Missing message digest in the signed attributes.");
+    }
+  }
+  bssl::UniquePtr<EVP_PKEY> key(
+      X509_get_pubkey(const_cast<X509*>(&certificate)));
+  if (key == nullptr) {
+    return OpenSslError(absl::StatusCode::kInvalidArgument,
+                        "Failed to get the public key from the certificate.");
+  }
+  int digest_nid = OBJ_cbs2nid(&signer.digest_algorithm.algorithm_oid.cbs());
+  if (digest_nid == NID_undef) {
+    return OpenSslError(absl::StatusCode::kUnimplemented,
+                        "Unknown digest algorithm.");
+  }
+  int signature_algorithm_nid =
+      OBJ_cbs2nid(signer.signature_algorithm.algorithm_oid.cbs_ptr());
+  // Get the digest NID from the signature NID if possible, otherwise use the
+  // signer's digest algorithm.
+  int signature_digest_nid = NID_undef;
+  if (OBJ_find_sigid_algs(signature_algorithm_nid, &signature_digest_nid,
+                          nullptr /* pkey nid */) != 1 ||
+      signature_digest_nid == NID_undef) {
+    signature_digest_nid = digest_nid;
+  }
+
+  // Check that the signature algorithm is supported.
+  switch (signature_algorithm_nid) {
+    case NID_rsassaPss:
+    case NID_rsaEncryption:
+    case NID_sha224WithRSAEncryption:
+    case NID_sha256WithRSAEncryption:
+    case NID_sha384WithRSAEncryption:
+    case NID_sha512WithRSAEncryption:
+    case NID_X9_62_id_ecPublicKey:
+      break;
+
+    // ECDSA support is defined in RFC 5753 and includes instructions for
+    // additional verification that `digest_nid` must match the value derived
+    // from `signature_algorithm_nid`. (See RFC 5753 section 2.1.1.) Note
+    // that the RFC permits the use of SHA-1 for ECDSA signatures, but we do
+    // not, as at the time of writing it had long been obsolete.
+    case NID_ecdsa_with_SHA224:
+    case NID_ecdsa_with_SHA256:
+    case NID_ecdsa_with_SHA384:
+    case NID_ecdsa_with_SHA512:
+      if (digest_nid != signature_digest_nid) {
+        return absl::InvalidArgumentError(absl::StrFormat(
+            R"(The digest algorithm does not match the value derived from the signature algorithm: digest_nid=%d, signature_digest_nid=%d)",
+            digest_nid, signature_digest_nid));
+      }
+      break;
+
+    default:
+      return OpenSslError(
+          absl::StatusCode::kUnimplemented,
+          absl::StrCat("Signature algorithm not implemented, NID: ",
+                       signature_algorithm_nid));
+  }
+
+  const EVP_MD* signature_md = EVP_get_digestbynid(signature_digest_nid);
+  if (signature_md == nullptr) {
+    return OpenSslError(absl::StatusCode::kUnimplemented,
+                        "Failed to get the signature EVP_MD object.");
+  }
+  bssl::ScopedEVP_MD_CTX md_context;
+  EVP_PKEY_CTX* pkey_ctx = nullptr;
+  if (EVP_DigestVerifyInit(md_context.get(), &pkey_ctx, signature_md,
+                           nullptr /* engine */, key.get()) != 1) {
+    return OpenSslError(absl::StatusCode::kInternal,
+                        "EVP_DigestVerifyInit failed.");
+  }
+  if (signature_algorithm_nid == NID_rsassaPss) {
+    auto status = SetPssParameters(signer, certificate, digest_nid,
+                                   signature_md, pkey_ctx);
+    if (!status.ok()) {
+      return status;
+    }
+  }
+
+  if (CBS_len(signer.raw_signed_attributes.cbs_ptr()) > 0) {
+    auto status = VerifySignedAttributesHash(signer, contents, digest_nid);
+    if (!status.ok()) {
+      return status;
+    }
+    // The signature is computed after replacing the implicit tag.
+    // https://tools.ietf.org/html/rfc2315#section-9.3
+    uint8_t tag = 0x31;  // SET
+    if (EVP_DigestVerifyUpdate(md_context.get(), &tag, 1) != 1 ||
+        EVP_DigestVerifyUpdate(
+            md_context.get(),
+            reinterpret_cast<const char*>(
+                CBS_data(signer.raw_signed_attributes.cbs_ptr()) + 1),
+            CBS_len(signer.raw_signed_attributes.cbs_ptr()) - 1) != 1) {
+      return OpenSslError(absl::StatusCode::kInternal,
+                          "EVP_DigestVerifyUpdate failed.");
+    }
+  } else {
+    // When there are no authenticated attributes the signature is computed on
+    // the contents.
+    for (const auto& chunk : contents) {
+      if (EVP_DigestVerifyUpdate(md_context.get(), CBS_data(chunk.cbs_ptr()),
+                                 CBS_len(chunk.cbs_ptr())) != 1) {
+        return OpenSslError(absl::StatusCode::kInternal,
+                            "EVP_DigestVerifyUpdate failed.");
+      }
+    }
+  }
+  if (EVP_DigestVerifyFinal(md_context.get(),
+                            const_cast<unsigned char*>(
+                                CBS_data(signer.signature_value.cbs_ptr())),
+                            CBS_len(signer.signature_value.cbs_ptr())) != 1) {
+    return OpenSslError(absl::StatusCode::kInvalidArgument,
+                        "Invalid signature");
+  }
+  SignatureInfo signature_info;
+  signature_info.digest_algorithm_nid = digest_nid;
+  signature_info.signature_algorithm_id = signature_algorithm_nid;
+  return signature_info;
+}
+
+}  // namespace credentio_cms
diff --git a/crypto/default/cms/verify_signature.h b/crypto/default/cms/verify_signature.h
new file mode 100644
index 0000000..4aabaa3
--- /dev/null
+++ b/crypto/default/cms/verify_signature.h
@@ -0,0 +1,46 @@
+// 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_CRYPTO_DEFAULT_CMS_VERIFY_SIGNATURE_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_VERIFY_SIGNATURE_H_
+
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "crypto/default/cms/cms_parser.h"
+#include "openssl/obj_mac.h"
+#include "openssl/ossl_typ.h"
+
+namespace credentio_cms {
+struct SignatureInfo {
+  // OpenSSL NID for the digest algorithm.
+  int digest_algorithm_nid = NID_undef;
+  // OpenSSL id for the signature algorithm.
+  int signature_algorithm_id = NID_undef;
+};
+
+// Verify a signature on the embedded content.
+// The X509 certificate trustworthiness is not validated by this function.
+// When the status is not OK the signature cannot be verified, the failure
+// code returned is only intended for debugging purposes.
+// The caller should also check that the digest and signature algorithms are
+// reasonable (i.e. not MD5...)
+absl::StatusOr<SignatureInfo> VerifySignature(
+    const Content& cms_content, const SignerInfo& signer,
+    const std::vector<ByteString>& contents, const X509& certificate);
+
+}  // namespace credentio_cms
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_CMS_VERIFY_SIGNATURE_H_
diff --git a/crypto/default/cms/verify_signature_test.cc b/crypto/default/cms/verify_signature_test.cc
new file mode 100644
index 0000000..48ff8a5
--- /dev/null
+++ b/crypto/default/cms/verify_signature_test.cc
@@ -0,0 +1,340 @@
+// 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 "crypto/default/cms/verify_signature.h"
+
+#include <stdint.h>
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "crypto/default/cms/certificates.h"
+#include "crypto/default/cms/cms_error_code.h"
+#include "crypto/default/cms/cms_parser.h"
+#include "crypto/default/cms/cms_test_helper.h"
+#include "crypto/default/cms/oids.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+#include "openssl/mem.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/obj_mac.h"
+#include "openssl/safestack.h"
+#include "openssl/stack.h"
+
+namespace credentio_cms {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+
+ByteString ByteStringFromString(const std::string& value) {
+  ByteString bs;
+  CBS_init(bs.cbs_ptr(), reinterpret_cast<const unsigned char*>(value.data()),
+           value.length());
+  return bs;
+}
+
+std::string ByteStringToString(const ByteString& bs) {
+  return std::string(reinterpret_cast<const char*>(CBS_data(bs.cbs_ptr())),
+                     CBS_len(bs.cbs_ptr()));
+}
+
+class VerifySignatureTest : public ::testing::Test {
+ protected:
+  void ParseOpaqueMessage(const char* filename) {
+    std::string message = GetFileContents(filename);
+    cms_object_der_ = GetCms(message);
+    std::string error_message;
+    EXPECT_EQ(ErrorCode::OK,
+              ParseCms(cms_object_der_.data(), cms_object_der_.length(), &cms_,
+                       &error_message))
+        << "Failed to parse: " << filename << " error: " << error_message;
+  }
+
+  void ParseMessage(const char* filename) {
+    std::string message = GetFileContents(filename);
+    // The first mime part in the message, in the tests this is the clear text.
+    first_part_ = GetFirstMimePart(message);
+    cms_object_der_ = GetCms(message);
+    std::string error_message;
+    EXPECT_EQ(ErrorCode::OK,
+              ParseCms(cms_object_der_.data(), cms_object_der_.length(), &cms_,
+                       &error_message))
+        << error_message;
+  }
+
+  // Pick the first certificate matching the signer restrictions, or null if
+  // none was found.
+  X509* GetFirstMatchingCert(const Content& content, const SignerInfo& signer) {
+    certificate_stack_.reset(GetAllCertificates(content));
+    if (certificate_stack_ == nullptr) {
+      return nullptr;
+    }
+    std::vector<X509*> matches =
+        GetSignerCertificates(certificate_stack_.get(), signer);
+    if (matches.empty()) {
+      return nullptr;
+    }
+    return matches[0];
+  }
+
+  std::string first_part_;
+  std::string cms_object_der_;
+  Content cms_;
+  bssl::UniquePtr<STACK_OF(X509)> certificate_stack_;
+};
+
+class VerifyOpaqueSignaturesTest
+    : public VerifySignatureTest,
+      public ::testing::WithParamInterface<const char*> {};
+
+TEST_P(VerifyOpaqueSignaturesTest, CheckSignature) {
+  ParseOpaqueMessage(GetParam());
+  ASSERT_EQ(1, cms_.signers.size());
+  ASSERT_EQ(1, cms_.content.size());
+
+  for (const auto& signer : cms_.signers) {
+    X509* certificate = GetFirstMatchingCert(cms_, signer);
+    ASSERT_FALSE(certificate == nullptr)
+        << "Failed to get the certificate for: " << GetParam()
+        << " and Issuer: " << CBSToName(signer.issuer_name);
+    auto status = VerifySignature(cms_, signer, cms_.content, *certificate);
+    EXPECT_TRUE(status.ok()) << status.status();
+
+    // Sanity check: change the first bit and check that the signature fails.
+    std::string data = ByteStringToString(cms_.content[0]);
+    data[0] ^= 0x80;
+    ByteString modified = ByteStringFromString(data);
+    EXPECT_FALSE(VerifySignature(cms_, signer, {modified}, *certificate).ok());
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(EmbeddedContent, VerifyOpaqueSignaturesTest,
+                         testing::Values("signed-openssl-opaque.msg",
+                                         "signed-openssl-ecdsa.msg"));
+
+class VerifyDetachedSignaturesTest
+    : public VerifySignatureTest,
+      public ::testing::WithParamInterface<const char*> {};
+
+TEST_P(VerifyDetachedSignaturesTest, CheckSignatureWithExternalContent) {
+  ParseMessage(GetParam());
+  ASSERT_EQ(1, cms_.signers.size());
+  EXPECT_EQ(0, cms_.content.size());
+  ByteString data = ByteStringFromString(first_part_);
+
+  for (const auto& signer : cms_.signers) {
+    X509* certificate = GetFirstMatchingCert(cms_, signer);
+    ASSERT_FALSE(certificate == nullptr);
+    auto status = VerifySignature(cms_, signer, {data}, *certificate);
+    EXPECT_TRUE(status.ok()) << status.status();
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(ExternalContent, VerifyDetachedSignaturesTest,
+                         testing::Values("signed-openssl-detached.msg",
+                                         "signed-outlook-mac-2011.msg",
+                                         "signed-thunderbird.msg",
+                                         "signed-openssl-pss.msg",
+                                         "signed-openssl-sha256.msg"));
+
+class VerifyFailedSignaturesTest
+    : public VerifySignatureTest,
+      public ::testing::WithParamInterface<const char*> {};
+
+TEST_P(VerifyFailedSignaturesTest, CheckSignatureWithExternalContent) {
+  ParseMessage(GetParam());
+  ASSERT_EQ(1, cms_.signers.size());
+  EXPECT_EQ(0, cms_.content.size());
+  ByteString data = ByteStringFromString(first_part_);
+
+  for (const auto& signer : cms_.signers) {
+    X509* certificate = GetFirstMatchingCert(cms_, signer);
+    ASSERT_FALSE(certificate == nullptr);
+    auto status = VerifySignature(cms_, signer, {data}, *certificate);
+    EXPECT_FALSE(status.ok()) << status.status();
+  }
+}
+INSTANTIATE_TEST_SUITE_P(
+    ExternalContent, VerifyFailedSignaturesTest,
+    testing::Values("signed-type-mismatch.msg",
+                    "signed-unknown-hash-algorithm.msg",
+                    "signed-unknown-signature-algorithm.msg"));
+
+TEST_F(VerifySignatureTest, MissingSignedAttributes) {
+  // The signed attributes are needed if the the envelope content type is not
+  // data.
+  ParseMessage("signed-openssl-detached.msg");
+  auto& signer = cms_.signers[0];
+  // Clear the signed attributes.
+  CBS_init(signer.raw_signed_attributes.cbs_ptr(), nullptr, 0);
+  CBS_init(cms_.content_type.cbs_ptr(), kSignedDataOid, sizeof(kSignedDataOid));
+
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  auto status = VerifySignature(cms_, signer, {data}, *certificate);
+  EXPECT_FALSE(status.ok()) << status.status();
+}
+
+TEST_F(VerifySignatureTest, MissingSignerDigest) {
+  ParseMessage("signed-openssl-detached.msg");
+  auto& signer = cms_.signers[0];
+  // Clear the signed message digest attribute.
+  CBS_init(signer.message_digest.cbs_ptr(), nullptr, 0);
+
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  auto status = VerifySignature(cms_, signer, {data}, *certificate);
+  EXPECT_FALSE(status.ok()) << status.status();
+}
+
+TEST_F(VerifySignatureTest, MissingContentTypeSigned) {
+  ParseMessage("signed-openssl-detached.msg");
+  auto& signer = cms_.signers[0];
+  // Clear the signed content_type_signed attribute.
+  CBS_init(signer.content_type_signed.cbs_ptr(), nullptr, 0);
+
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  auto status = VerifySignature(cms_, signer, {data}, *certificate);
+  EXPECT_FALSE(status.ok()) << status.status();
+}
+
+TEST_F(VerifySignatureTest, CertificateWithBrokenPubKey) {
+  ParseMessage("signed-openssl-detached.msg");
+  ByteString data = ByteStringFromString(first_part_);
+  // user1_bad_public_key has a public key with an unknown OID.
+  bssl::UniquePtr<X509> certificate(
+      PemToCertificate(GetFileContents("user1_bad_public_key.pem")));
+  auto status = VerifySignature(cms_, cms_.signers[0], {data}, *certificate);
+  EXPECT_FALSE(status.ok()) << status.status();
+}
+
+TEST_F(VerifySignatureTest, BadSignatureDigestAlgorithm) {
+  ParseMessage("signed-openssl-detached.msg");
+  auto& signer = cms_.signers[0];
+  // Use an OID known to OpenSSL but that is not a valid hash or signature
+  // algorithm with an implied hash function.
+  // 1.2.840.113549.3.7  DES-EDE3-CBC
+  auto oid = NidToDerString(NID_des_ede3_cbc);
+  signer.signature_algorithm.algorithm_oid = ByteStringFromString(oid);
+  signer.digest_algorithm.algorithm_oid = ByteStringFromString(oid);
+
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  auto status = VerifySignature(cms_, signer, {data}, *certificate);
+  EXPECT_FALSE(status.ok()) << status.status();
+}
+
+TEST_F(VerifySignatureTest, BadHashDigestAlgorithm) {
+  ParseMessage("signed-openssl-sha256.msg");
+  auto& signer = cms_.signers[0];
+  // Use a signature OID that provides an explicit hashing algorithm.
+  auto sig_oid = NidToDerString(NID_sha256WithRSAEncryption);
+  signer.signature_algorithm.algorithm_oid = ByteStringFromString(sig_oid);
+
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  auto status = VerifySignature(cms_, signer, {data}, *certificate);
+  // Make sure that the signature algorithm is correct.
+  EXPECT_TRUE(status.ok()) << status.status();
+
+  // Use an OID known to OpenSSL but that is not a valid hash algorithm.
+  // 1.2.840.113549.3.7  DES-EDE3-CBC
+  auto digest_oid = NidToDerString(NID_des_ede3_cbc);
+  signer.digest_algorithm.algorithm_oid = ByteStringFromString(digest_oid);
+  status = VerifySignature(cms_, signer, {data}, *certificate);
+  EXPECT_FALSE(status.ok()) << status.status();
+}
+
+TEST_F(VerifySignatureTest, WrongSignedHashLength) {
+  ParseMessage("signed-openssl-sha256.msg");
+  auto& signer = cms_.signers[0];
+  // Shrink the digest length by one.
+  CBS_init(signer.message_digest.cbs_ptr(),
+           CBS_data(signer.message_digest.cbs_ptr()),
+           CBS_len(signer.message_digest.cbs_ptr()) - 1);
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  auto status = VerifySignature(cms_, signer, {data}, *certificate);
+  EXPECT_FALSE(status.ok()) << status.status();
+}
+
+TEST_F(VerifySignatureTest, MissingPSSParameters) {
+  ParseMessage("signed-openssl-pss.msg");
+  auto& signer = cms_.signers[0];
+  CBS_init(signer.signature_algorithm.parameter.cbs_ptr(), nullptr, 0);
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  auto status = VerifySignature(cms_, signer, {data}, *certificate);
+  EXPECT_FALSE(status.ok()) << status.status();
+}
+
+TEST_F(VerifySignatureTest, CheckSignatureInfo) {
+  ParseMessage("signed-openssl-pss.msg");
+  auto& signer = cms_.signers[0];
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  auto status = VerifySignature(cms_, signer, {data}, *certificate);
+  ASSERT_TRUE(status.ok()) << status.status();
+  EXPECT_EQ(NID_rsassaPss, status.value().signature_algorithm_id);
+  EXPECT_EQ(NID_sha256, status.value().digest_algorithm_nid);
+}
+
+TEST_F(VerifySignatureTest, MismatchedHashDigestAlgorithmEcdsa) {
+  ParseOpaqueMessage("signed-openssl-ecdsa.msg");
+  auto& signer = cms_.signers[0];
+  // Use a signature OID that provides an explicit hashing algorithm and use the
+  // right hash algorithm to make sure the test case is valid.
+  auto sig_oid = NidToDerString(NID_ecdsa_with_SHA256);
+  signer.signature_algorithm.algorithm_oid = ByteStringFromString(sig_oid);
+  auto good_digest_oid = NidToDerString(NID_sha256);
+  signer.digest_algorithm.algorithm_oid = ByteStringFromString(good_digest_oid);
+
+  ByteString data = ByteStringFromString(first_part_);
+  X509* certificate = GetFirstMatchingCert(cms_, signer);
+  ASSERT_FALSE(certificate == nullptr);
+  // Make sure signature algorithm is correct.
+  EXPECT_THAT(VerifySignature(cms_, signer, cms_.content, *certificate),
+              IsOk());
+
+  // Set hash algorithm to a supported value that is different from the one
+  // implied by the signature algorithm.
+  auto bad_digest_oid = NidToDerString(NID_sha384);
+  signer.digest_algorithm.algorithm_oid = ByteStringFromString(bad_digest_oid);
+  EXPECT_THAT(
+      VerifySignature(cms_, signer, {data}, *certificate),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          R"(The digest algorithm does not match the value derived from the signature algorithm: digest_nid=673, signature_digest_nid=672)"));
+}
+
+}  // namespace
+}  // namespace credentio_cms
diff --git a/crypto/default/compliance_checker.cc b/crypto/default/compliance_checker.cc
new file mode 100644
index 0000000..da71c11
--- /dev/null
+++ b/crypto/default/compliance_checker.cc
@@ -0,0 +1,184 @@
+// 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 "crypto/default/compliance_checker.h"
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/ec.h"
+#include "openssl/ec_key.h"
+#include "openssl/evp.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/rsa.h"
+#include "openssl/x509.h"
+
+namespace credentio {
+namespace {
+
+constexpr int kMinRsaModulusBits = 2048;
+
+absl::Status IsValidCurve(const EVP_PKEY* absl_nonnull evp_key) {
+  EC_KEY* ec_key = EVP_PKEY_get0_EC_KEY(evp_key);
+  if (ec_key == nullptr) {
+    // This should never happen. Return an internal error to indicate a bug.
+    return absl::InternalError("EVP key is not an EC key");
+  }
+  const EC_GROUP* ec_group = EC_KEY_get0_group(ec_key);
+  if (ec_group == nullptr) {
+    // This should never happen. Return an internal error to indicate a bug.
+    return absl::InvalidArgumentError("EC key does not have an EC group");
+  }
+  int curve_name = EC_GROUP_get_curve_name(ec_group);
+  switch (curve_name) {
+    case NID_X9_62_prime256v1:
+    case NID_secp384r1:
+    case NID_secp521r1:
+      return absl::OkStatus();
+    default:
+      return absl::InvalidArgumentError(absl::StrCat(
+          "EC curve does not fulfill C2PA requirements: ", curve_name));
+  }
+}
+
+absl::Status IsValidModulus(const EVP_PKEY* absl_nonnull evp_key) {
+  RSA* rsa = EVP_PKEY_get0_RSA(evp_key);
+  if (rsa == nullptr) {
+    // This should never happen. Return an internal error to indicate a bug.
+    return absl::InternalError("EVP key is not an RSA key");
+  }
+  if (unsigned int length = RSA_bits(rsa); length < kMinRsaModulusBits) {
+    return absl::InvalidArgumentError(absl::StrFormat(
+        "RSA modulus does not fulfill C2PA requirements: The modulus length n "
+        "= %d bits (minimum required is %d bits)",
+        length, kMinRsaModulusBits));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status IsValidTbsCertificate(const X509* absl_nonnull x509) {
+  const ASN1_BIT_STRING *issuer_uid, *subject_uid;
+  X509_get0_uids(x509, &issuer_uid, &subject_uid);
+  if (issuer_uid != nullptr || subject_uid != nullptr) {
+    return absl::InvalidArgumentError(
+        "Certificate contains issuerUniqueID or subjectUniqueID fields");
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+// This method checks the requirements in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements.
+absl::Status ComplianceChecker::IsValidC2paCertificate(const X509* x509,
+                                                       bool is_leaf) {
+  if (x509 == nullptr) {
+    return absl::InvalidArgumentError("Certificate is null");
+  }
+
+  if (is_leaf) {
+    int bc_critical = -1;
+    bssl::UniquePtr<BASIC_CONSTRAINTS> basic_constraints(
+        static_cast<BASIC_CONSTRAINTS*>(X509_get_ext_d2i(
+            x509, NID_basic_constraints, &bc_critical, nullptr)));
+    if (basic_constraints == nullptr) {
+      if (bc_critical != -1) {
+        return absl::InvalidArgumentError(
+            "Malformed or invalid Basic Constraints extension in the "
+            "end-entity certificate.");
+      }
+    } else if (basic_constraints->ca) {
+      return absl::InvalidArgumentError(
+          "The cA boolean flag in the Basic Constraints extension of the "
+          "end-entity certificate MUST be set to FALSE.");
+    }
+
+    int ku_critical = -1;
+    bssl::UniquePtr<ASN1_BIT_STRING> key_usage(static_cast<ASN1_BIT_STRING*>(
+        X509_get_ext_d2i(x509, NID_key_usage, &ku_critical, nullptr)));
+    if (key_usage == nullptr) {
+      if (ku_critical != -1) {
+        return absl::InvalidArgumentError(
+            "Malformed or invalid Key Usage extension in the end-entity "
+            "certificate.");
+      }
+    } else if (ASN1_BIT_STRING_get_bit(key_usage.get(), 5) != 0) {
+      return absl::InvalidArgumentError(
+          "The keyCertSign bit in the Key Usage extension of the "
+          "end-entity certificate MUST be set to FALSE.");
+    }
+  }
+  EVP_PKEY* pubkey = X509_get0_pubkey(x509);
+  if (pubkey == nullptr) {
+    return absl::InvalidArgumentError("Certificate does not have a public key");
+  }
+
+  // * The algorithm requirements in the spec must be satisfied.
+  // A subset of these checks are performed when COSE verifier is constructing
+  // the `CoseSign1` structure so we do not need to repeat them here.
+  switch (EVP_PKEY_id(pubkey)) {
+    case EVP_PKEY_EC:  // id_ecPublicKey
+      // Quoting
+      // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements,
+      // if the `algorithm` field of the `algorithm` field of the certificate’s
+      // `subjectPublicKeyInfo` is `id-ecPublicKey`, the `parameters` field
+      // shall be one of the following  named curves from RFC 5480,
+      // section 2.1.1.1: "prime256v1", "secp384r1", "secp521r1".
+      ABSL_RETURN_IF_ERROR(IsValidCurve(pubkey));
+      break;
+    case EVP_PKEY_RSA:      // rsaEncryption
+    case EVP_PKEY_RSA_PSS:  // rsaPSS
+      // Quoting
+      // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements,
+      // if the `algorithm` field of the `algorithm` field of the certificate’s
+      // `subjectPublicKeyInfo` is `rsaEncryption` or `rsaPSS`, the modulus
+      // field of the parameters field shall have a length of at least 2048
+      // bits.
+      ABSL_RETURN_IF_ERROR(IsValidModulus(pubkey));
+      // BoringSSL does not support id-RSASSA-PSS key encoding, and
+      // `RSA_get0_pss_params` will always return nullptr. So we cannot perform
+      // the following checks required by the C2PA spec for RSA-PSS:
+      // * `hashAlgorithm` and `maskGenAlgorithm` are present in the PSS params
+      // and they have the same value.
+      // * The value is one of the following: `id-sha256`, id-sha385` or
+      // `id-sha512`.
+      //
+      // In the case of a malformed key where the `hashAlgorithm` and
+      // `maskGenAlgorithm` have different values, the signature verification
+      // will fail. `CoseSign1Verifier::Verify` will also return an error if the
+      // signing algorithm is RSA-PSS but the hash algorithm is not SHA-256,
+      // SHA-358 or SHA-512. So we do not need to check these two items here.
+      break;
+  }
+
+  // * The version must be equal to v3.
+  if (X509_get_version(x509) != X509_VERSION_3) {
+    return absl::InvalidArgumentError("Certificate version is not v3");
+  }
+
+  // * The `issuerUniqueID` and `subjectUniqueID` optional fields of the
+  // `TBSCertificate` sequence shall not be present, as per RFC 5280,
+  // section 4.1.2.8.
+  ABSL_RETURN_IF_ERROR(IsValidTbsCertificate(x509));
+
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/crypto/default/compliance_checker.h b/crypto/default/compliance_checker.h
new file mode 100644
index 0000000..8a288c0
--- /dev/null
+++ b/crypto/default/compliance_checker.h
@@ -0,0 +1,31 @@
+// 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_CRYPTO_DEFAULT_COMPLIANCE_CHECKER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_COMPLIANCE_CHECKER_H_
+
+#include "absl/status/status.h"
+#include "openssl/base.h"
+
+namespace credentio {
+
+class ComplianceChecker {
+ public:
+  static absl::Status IsValidC2paCertificate(const X509* x509, bool is_leaf);
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_COMPLIANCE_CHECKER_H_
diff --git a/crypto/default/compliance_checker_test.cc b/crypto/default/compliance_checker_test.cc
new file mode 100644
index 0000000..7cccf70
--- /dev/null
+++ b/crypto/default/compliance_checker_test.cc
@@ -0,0 +1,253 @@
+// 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 "crypto/default/compliance_checker.h"
+
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/x509.h"
+#include "testing/crypto_testing.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(ComplianceCheckerTest, NullCertificate) {
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(nullptr, /*is_leaf=*/true),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Certificate is null")));
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(nullptr, /*is_leaf=*/false),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Certificate is null")));
+}
+
+TEST(ComplianceCheckerTest, NoPublicKey) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_1);
+  EXPECT_THAT(ComplianceChecker::IsValidC2paCertificate(cert.get(),
+                                                        /*is_leaf=*/true),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Certificate does not have a public key")));
+  EXPECT_THAT(ComplianceChecker::IsValidC2paCertificate(cert.get(),
+                                                        /*is_leaf=*/false),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Certificate does not have a public key")));
+}
+
+TEST(ComplianceCheckerTest, VersionNotV3) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_1);
+  bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEcKey(NID_secp384r1);
+  X509_set_pubkey(cert.get(), evp_key.get());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Certificate version is not v3")));
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Certificate version is not v3")));
+}
+
+TEST(ComplianceCheckerTest, EcdsaGoodCurve) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_3);
+  bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEcKey(NID_secp384r1);
+  X509_set_pubkey(cert.get(), evp_key.get());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+      IsOk());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false),
+      IsOk());
+}
+
+TEST(ComplianceCheckerTest, EcdsaBadCurve) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_3);
+  bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEcKey(NID_secp224r1);
+  X509_set_pubkey(cert.get(), evp_key.get());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("EC curve does not fulfill C2PA requirements")));
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("EC curve does not fulfill C2PA requirements")));
+}
+
+TEST(ComplianceCheckerTest, Rsa) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_3);
+  bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestRsaKey(2048);
+  X509_set_pubkey(cert.get(), evp_key.get());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+      IsOk());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false),
+      IsOk());
+}
+
+TEST(ComplianceCheckerTest, RsaBadModulus) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_3);
+  bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestRsaKey(1024);
+  X509_set_pubkey(cert.get(), evp_key.get());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "RSA modulus does not fulfill C2PA requirements: The modulus "
+              "length n = 1024 bits (minimum required is 2048 bits)")));
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "RSA modulus does not fulfill C2PA requirements: The modulus "
+              "length n = 1024 bits (minimum required is 2048 bits)")));
+}
+
+TEST(ComplianceCheckerTest, Ed) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_3);
+  bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey();
+  X509_set_pubkey(cert.get(), evp_key.get());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+      IsOk());
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false),
+      IsOk());
+}
+
+TEST(ComplianceCheckerTest, CALeafCertificate) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_3);
+  bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey();
+  X509_set_pubkey(cert.get(), evp_key.get());
+
+  bssl::UniquePtr<BASIC_CONSTRAINTS> bc(BASIC_CONSTRAINTS_new());
+  bc->ca = 1;
+  ASSERT_TRUE(X509_add1_ext_i2d(cert.get(), NID_basic_constraints, bc.get(),
+                                /*crit=*/1, /*flags=*/0));
+
+  // Valid as non-leaf.
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false),
+      IsOk());
+
+  // Invalid as leaf.
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("end-entity certificate MUST be set to FALSE")));
+}
+
+TEST(ComplianceCheckerTest, KeyCertSignLeafCertificate) {
+  bssl::UniquePtr<X509> cert(X509_new());
+  X509_set_version(cert.get(), X509_VERSION_3);
+  bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey();
+  X509_set_pubkey(cert.get(), evp_key.get());
+
+  bssl::UniquePtr<ASN1_BIT_STRING> ku(ASN1_BIT_STRING_new());
+  std::vector<uint8_t> bits = {0b00000100};  // Bit 5 is keyCertSign
+  ASSERT_TRUE(ASN1_BIT_STRING_set(ku.get(), bits.data(), bits.size()));
+  ASSERT_TRUE(X509_add1_ext_i2d(cert.get(), NID_key_usage, ku.get(),
+                                /*crit=*/1, /*flags=*/0));
+
+  // Valid as non-leaf.
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/false),
+      IsOk());
+
+  // Invalid as leaf.
+  EXPECT_THAT(
+      ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("keyCertSign bit in the Key Usage extension")));
+}
+
+TEST(ComplianceCheckerTest, MalformedExtensionsLeafCertificate) {
+  // Malformed Basic Constraints in leaf certificate.
+  {
+    bssl::UniquePtr<X509> cert(X509_new());
+    X509_set_version(cert.get(), X509_VERSION_3);
+    bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey();
+    X509_set_pubkey(cert.get(), evp_key.get());
+
+    bssl::UniquePtr<ASN1_OCTET_STRING> malformed_data(ASN1_OCTET_STRING_new());
+    std::vector<uint8_t> invalid_der = {
+        0x05, 0x00};  // Invalid DER for BASIC_CONSTRAINTS
+    ASSERT_TRUE(ASN1_OCTET_STRING_set(malformed_data.get(), invalid_der.data(),
+                                      invalid_der.size()));
+
+    bssl::UniquePtr<X509_EXTENSION> ext(X509_EXTENSION_new());
+    ASSERT_TRUE(X509_EXTENSION_set_object(ext.get(),
+                                          OBJ_nid2obj(NID_basic_constraints)));
+    ASSERT_TRUE(X509_EXTENSION_set_data(ext.get(), malformed_data.get()));
+    ASSERT_TRUE(X509_add_ext(cert.get(), ext.get(), -1));
+
+    EXPECT_THAT(
+        ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+        StatusIs(
+            absl::StatusCode::kInvalidArgument,
+            HasSubstr("Malformed or invalid Basic Constraints extension")));
+  }
+
+  // Malformed Key Usage in leaf certificate.
+  {
+    bssl::UniquePtr<X509> cert(X509_new());
+    X509_set_version(cert.get(), X509_VERSION_3);
+    bssl::UniquePtr<EVP_PKEY> evp_key = CreateTestEdKey();
+    X509_set_pubkey(cert.get(), evp_key.get());
+
+    bssl::UniquePtr<ASN1_OCTET_STRING> malformed_data(ASN1_OCTET_STRING_new());
+    std::vector<uint8_t> invalid_der = {0x05,
+                                        0x00};  // Invalid DER for KeyUsage
+    ASSERT_TRUE(ASN1_OCTET_STRING_set(malformed_data.get(), invalid_der.data(),
+                                      invalid_der.size()));
+
+    bssl::UniquePtr<X509_EXTENSION> ext(X509_EXTENSION_new());
+    ASSERT_TRUE(
+        X509_EXTENSION_set_object(ext.get(), OBJ_nid2obj(NID_key_usage)));
+    ASSERT_TRUE(X509_EXTENSION_set_data(ext.get(), malformed_data.get()));
+    ASSERT_TRUE(X509_add_ext(cert.get(), ext.get(), -1));
+
+    EXPECT_THAT(
+        ComplianceChecker::IsValidC2paCertificate(cert.get(), /*is_leaf=*/true),
+        StatusIs(absl::StatusCode::kInvalidArgument,
+                 HasSubstr("Malformed or invalid Key Usage extension")));
+  }
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/default/default_crypto_read_handler.cc b/crypto/default/default_crypto_read_handler.cc
new file mode 100644
index 0000000..b92dd8d
--- /dev/null
+++ b/crypto/default/default_crypto_read_handler.cc
@@ -0,0 +1,359 @@
+// 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 "crypto/default/default_crypto_read_handler.h"
+
+#include <cstddef>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/log/absl_check.h"
+#include "absl/log/absl_log.h"
+#include "absl/log/check.h"
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "cose/simple_cms_parser.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/default/eku_verifier.h"
+#include "crypto/default/trust_store.h"
+#include "crypto/default/x509_certificate.h"
+#include "openssl/pki/ocsp.h"
+#include "openssl/pki/verify.h"
+#include "openssl/pki/verify_error.h"
+#include "tsp/timestamp_verifier.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+namespace {
+
+class DefaultParsedCertificates : public ParsedCertificates {
+ public:
+  DefaultParsedCertificates() = default;
+  ~DefaultParsedCertificates() override = default;
+  // If `all_claim_signer_roots_` is nullptr, VerifyClaimSignerTrust skips trust
+  // checks. If `legacy_claim_signer_roots_` is nullptr, VerifyClaimSignerTrust
+  // accepts only the C2PA EKU.
+  explicit DefaultParsedCertificates(
+      std::vector<std::unique_ptr<X509Certificate>> certificate_chain_x509,
+      std::vector<std::string> certificate_chain_der,
+      bssl::VerifyTrustStore* absl_nullable all_claim_signer_roots,
+      bssl::VerifyTrustStore* absl_nullable legacy_claim_signer_roots)
+      : certificate_chain_x509_(std::move(certificate_chain_x509)),
+        certificate_chain_der_(std::move(certificate_chain_der)),
+        all_claim_signer_roots_(all_claim_signer_roots),
+        legacy_claim_signer_roots_(legacy_claim_signer_roots) {
+    ABSL_CHECK(!certificate_chain_x509_.empty());
+    ABSL_CHECK_EQ(certificate_chain_der_.size(),
+                  certificate_chain_x509_.size());
+  }
+
+  absl::StatusOr<std::vector<std::string>> VerifyClaimSignerTrust(
+      absl::Time content_time) const override {
+    if (all_claim_signer_roots_ == nullptr) {
+      ABSL_LOG_EVERY_N_SEC(INFO, 60)
+          << "Bypassing C2PA claim signer certificate trust check.";
+      return std::vector<std::string>();
+    }
+    if (certificate_chain_der_.size() > 3) {
+      return absl::UnauthenticatedError(
+          absl::StrCat("Excessive certificate chain length: ",
+                       certificate_chain_der_.size()));
+    }
+
+    bssl::CertificateVerifyOptions opts;
+    opts.leaf_cert = certificate_chain_der_.at(0);
+    if (EkuVerifier::CheckLeafCertEku(opts.leaf_cert)) {
+      // `EkuVerifier::CheckLeafCertEku` returns true iff the version 2.2
+      // requirements are met:
+      // 1. The leaf certificate asserts the `digitalSignature` KU bit.
+      // 2. The leaf certificate has the C2PA claim signing EKU.
+      // 3. The leaf certificate does not have the `anyExtendedKeyUsage` EKU.
+      // So, we can skip the EKU checks in BoringSSL.
+      opts.key_purpose = bssl::CertificateVerifyOptions::KeyPurpose::ANY_EKU;
+      opts.trust_store = all_claim_signer_roots_;
+    } else {
+      // Version 2.2 requirements are not met. Fall back to version 2.1 checks
+      // in BoringSSL.
+      opts.key_purpose =
+          bssl::CertificateVerifyOptions::KeyPurpose::C2PA_MANIFEST;
+      if (legacy_claim_signer_roots_ == nullptr) {
+        return absl::InvalidArgumentError(
+            "Extended Key Usage c2pa-kp-claimSigning not present");
+      }
+      opts.trust_store = legacy_claim_signer_roots_;
+    }
+    for (int i = 1; i < certificate_chain_der_.size(); ++i) {
+      opts.intermediates.push_back(certificate_chain_der_.at(i));
+    }
+    opts.time = absl::ToTimeT(content_time);
+    bssl::VerifyError error;
+    // This check only verifies that there is a valid chain from the leaf
+    // certificate to one of the trusted root certificates. Note that this is
+    // a relaxed version of the requirement in
+    // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#x509_certificates
+    // which requires the certificates in the chain to be ordered starting with
+    // the certificate containing the end-entity key (i.e., the leaf
+    // certificate) followed by the certificate that signed it, and so on.
+    auto chain = bssl::CertificateVerify(opts, &error);
+    if (!chain) {
+      switch (error.Code()) {
+        case bssl::VerifyError::StatusCode::CERTIFICATE_NOT_YET_VALID:
+        case bssl::VerifyError::StatusCode::CERTIFICATE_EXPIRED:
+          return absl::OutOfRangeError(
+              absl::StrCat("certificate outside validity period: ",
+                           error.DiagnosticString()));
+        case bssl::VerifyError::StatusCode::PATH_NOT_FOUND:
+          return absl::UnauthenticatedError(absl::StrCat(
+              "untrusted claim signer: ", error.DiagnosticString()));
+        default:
+          return absl::InvalidArgumentError(absl::StrCat(
+              "Failed to verify the trust chain: ", error.DiagnosticString()));
+      }
+    }
+    return *chain;
+  }
+
+  absl::Status VerifySignature(absl::string_view signature,
+                               absl::string_view data,
+                               SigningAlgorithm algorithm) const override {
+    return leaf().VerifySignature(signature, data, algorithm);
+  }
+
+  size_t GetCertificateCount() const override {
+    return certificate_chain_x509_.size();
+  }
+
+  absl::StatusOr<std::string> GetSubject(size_t index) const override {
+    if (index >= certificate_chain_x509_.size()) {
+      return absl::OutOfRangeError("Certificate index out of bounds");
+    }
+    return certificate_chain_x509_[index]->GetSubject();
+  }
+
+  absl::StatusOr<std::string> GetIssuer(size_t index) const override {
+    if (index >= certificate_chain_x509_.size()) {
+      return absl::OutOfRangeError("Certificate index out of bounds");
+    }
+    return certificate_chain_x509_[index]->GetIssuer();
+  }
+
+  absl::StatusOr<absl::Time> GetStartTime(size_t index) const override {
+    if (index >= certificate_chain_x509_.size()) {
+      return absl::OutOfRangeError("Certificate index out of bounds");
+    }
+    return certificate_chain_x509_[index]->StartTime();
+  }
+
+  absl::StatusOr<absl::Time> GetEndTime(size_t index) const override {
+    if (index >= certificate_chain_x509_.size()) {
+      return absl::OutOfRangeError("Certificate index out of bounds");
+    }
+    return certificate_chain_x509_[index]->EndTime();
+  }
+
+  absl::StatusOr<std::string> GetSerialNumberHex(size_t index) const override {
+    if (index >= certificate_chain_x509_.size()) {
+      return absl::OutOfRangeError("Certificate index out of bounds");
+    }
+    return certificate_chain_x509_[index]->GetSerialNumberHex();
+  }
+
+  absl::StatusOr<std::string> GetAssuranceLevel(size_t index) const override {
+    if (index >= certificate_chain_x509_.size()) {
+      return absl::OutOfRangeError("Certificate index out of bounds");
+    }
+    return certificate_chain_x509_[index]->GetAssuranceLevel();
+  }
+
+  absl::StatusOr<std::string> GetConformingProductId(
+      size_t index) const override {
+    if (index >= certificate_chain_x509_.size()) {
+      return absl::OutOfRangeError("Certificate index out of bounds");
+    }
+    return certificate_chain_x509_[index]->GetConformingProductId();
+  }
+
+  const X509Certificate& leaf() const {
+    return *certificate_chain_x509_.front();
+  }
+
+#ifndef NDEBUG
+  [[maybe_unused]] std::string CertChainDebugString() const {
+    std::string s;
+    for (const auto& cert : certificate_chain_x509_) {
+      absl::StrAppend(&s, cert->DebugString());
+    }
+    return s;
+  }
+#endif
+
+ private:
+  std::vector<std::unique_ptr<X509Certificate>> certificate_chain_x509_;
+  std::vector<std::string> certificate_chain_der_;
+  bssl::VerifyTrustStore* absl_nullable all_claim_signer_roots_;
+  bssl::VerifyTrustStore* absl_nullable legacy_claim_signer_roots_;
+};
+
+// An implementation of CryptoReadHandler intended for use in production.
+// It uses Tink, BoringSSL, and SimpleCMS.
+class DefaultCryptoReadHandler : public CryptoReadHandler {
+ public:
+  DefaultCryptoReadHandler(
+      absl_nullable std::unique_ptr<bssl::VerifyTrustStore>
+          all_claim_signer_roots,
+      absl_nullable std::unique_ptr<bssl::VerifyTrustStore>
+          legacy_claim_signer_roots,
+      absl_nullable std::unique_ptr<bssl::VerifyTrustStore> tsa_roots,
+      TrustEnvironment trust_environment)
+      : all_claim_signer_roots_(std::move(all_claim_signer_roots)),
+        legacy_claim_signer_roots_(std::move(legacy_claim_signer_roots)),
+        tsa_roots_(std::move(tsa_roots)),
+        timestamp_verifier_(
+            std::make_unique<TimestampVerifier>(tsa_roots_.get())),
+        cms_parser_(std::make_unique<SimpleCmsParser>()),
+        trust_environment_(trust_environment) {}
+  ~DefaultCryptoReadHandler() override = default;
+
+  absl::StatusOr<std::unique_ptr<ParsedCertificates>> ParseCertificatesDer(
+      absl::Span<const absl::string_view> certificates) const override;
+
+  absl::StatusOr<OCSPRevocationStatus> VerifyOcspResponse(
+      absl::string_view ocsp_response_der, absl::string_view certificate_der,
+      absl::string_view issuer_certificate_der,
+      absl::Time verify_time) const override {
+    bssl::OCSPVerifyResult::ResponseStatus response_status;
+    bssl::OCSPRevocationStatus revocation_status = bssl::CheckOCSP(
+        ocsp_response_der, certificate_der, issuer_certificate_der,
+        absl::ToUnixSeconds(verify_time), std::nullopt, &response_status);
+
+    switch (response_status) {
+      case bssl::OCSPVerifyResult::PROVIDED:
+        switch (revocation_status) {
+          case bssl::OCSPRevocationStatus::GOOD:
+            return OCSPRevocationStatus::kGood;
+          case bssl::OCSPRevocationStatus::REVOKED:
+            return OCSPRevocationStatus::kRevoked;
+          case bssl::OCSPRevocationStatus::UNKNOWN:
+            return OCSPRevocationStatus::kUnknown;
+          default:
+            return absl::InternalError("Unknown revocation status");
+        }
+      case bssl::OCSPVerifyResult::NO_MATCHING_RESPONSE:
+        return absl::UnauthenticatedError(
+            "OCSP response signature failed verification or no matching "
+            "response found.");
+      case bssl::OCSPVerifyResult::INVALID_DATE:
+      case bssl::OCSPVerifyResult::BAD_PRODUCED_AT:
+        return absl::OutOfRangeError(
+            "verify_time is outside the valid time window of the OCSP "
+            "response.");
+      default:
+        return absl::InvalidArgumentError(
+            "OCSP response is malformed or invalid.");
+    }
+  }
+
+  absl::StatusOr<VerifiedTimestamp> VerifyTimestamp(
+      absl::string_view cms) const override {
+    ABSL_ASSIGN_OR_RETURN(auto parsed_token,
+                          cms_parser_->ParseTimestampToken(cms));
+    return timestamp_verifier_->VerifyTimestampToken(*parsed_token);
+  }
+
+  TrustEnvironment trust_environment() const override {
+    return trust_environment_;
+  }
+
+ private:
+  absl_nullable std::unique_ptr<bssl::VerifyTrustStore> all_claim_signer_roots_;
+  absl_nullable std::unique_ptr<bssl::VerifyTrustStore>
+      legacy_claim_signer_roots_;
+  absl_nullable std::unique_ptr<bssl::VerifyTrustStore> tsa_roots_;
+  std::unique_ptr<TimestampVerifier> timestamp_verifier_;
+  std::unique_ptr<SimpleCmsParser> cms_parser_;
+  TrustEnvironment trust_environment_;
+};
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ParsedCertificates>>
+DefaultCryptoReadHandler::ParseCertificatesDer(
+    absl::Span<const absl::string_view> certificates) const {
+  if (certificates.empty()) {
+    return absl::InvalidArgumentError("No leaf certificate provided");
+  }
+  // Construct the X.509 public key certificate from DER bytes.
+  // The first element of the certificate chain is the signer's public key DER
+  // bytes. The certificate chain starts with the signer's certificate and the
+  // following elements constitute the rest of the chain.
+  std::vector<std::unique_ptr<X509Certificate>> certificates_x509;
+  std::vector<std::string> certificates_der;
+  certificates_der.reserve(certificates.size());
+  for (int i = 0; i < certificates.size(); ++i) {
+    const auto& cert_der = certificates[i];
+    certificates_der.push_back(std::string(cert_der));
+    ABSL_ASSIGN_OR_RETURN(auto x509, X509Certificate::Create(/*der=*/cert_der));
+    ABSL_DVLOG(1) << "Cert validity start time = " << x509->StartTime();
+    ABSL_DVLOG(1) << "Cert validity end time = " << x509->EndTime();
+    if (x509 == nullptr) {
+      return absl::InternalError("X509Certificate::Create returned nullptr");
+    }
+    ABSL_RETURN_IF_ERROR(x509->IsValidC2paCertificate(/*is_leaf=*/(i == 0)));
+    certificates_x509.push_back(std::move(x509));
+  }
+  return std::make_unique<DefaultParsedCertificates>(
+      std::move(certificates_x509), std::move(certificates_der),
+      all_claim_signer_roots_.get(), legacy_claim_signer_roots_.get());
+}
+
+absl::StatusOr<std::unique_ptr<CryptoReadHandler>>
+CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options) {
+  std::unique_ptr<bssl::VerifyTrustStore> all_claim_signer_roots;
+  std::unique_ptr<bssl::VerifyTrustStore> legacy_claim_signer_roots;
+  if (!options.skip_claim_signer_trust_checks_for_test) {
+    if (!options.legacy_claim_signer_trust_anchors_pem.empty()) {
+      // If the string is nonempty, it must contain at least one cert.
+      ABSL_ASSIGN_OR_RETURN(
+          legacy_claim_signer_roots,
+          LoadTrustStore(options.legacy_claim_signer_trust_anchors_pem));
+    }
+    ABSL_ASSIGN_OR_RETURN(all_claim_signer_roots,
+                          LoadTrustStore(absl::StrCat(
+                              options.claim_signer_trust_anchors_pem,
+                              options.legacy_claim_signer_trust_anchors_pem)));
+  }
+  std::unique_ptr<bssl::VerifyTrustStore> tsa_roots;
+  if (!options.skip_tsa_trust_checks_for_test) {
+    ABSL_ASSIGN_OR_RETURN(tsa_roots,
+                          LoadTrustStore(options.tsa_trust_anchors_pem));
+  }
+  return std::make_unique<DefaultCryptoReadHandler>(
+      /*all_claim_signer_roots=*/std::move(all_claim_signer_roots),
+      /*legacy_claim_signer_roots=*/std::move(legacy_claim_signer_roots),
+      std::move(tsa_roots), options.trust_environment);
+}
+
+}  // namespace credentio
diff --git a/crypto/default/default_crypto_read_handler.h b/crypto/default/default_crypto_read_handler.h
new file mode 100644
index 0000000..005872a
--- /dev/null
+++ b/crypto/default/default_crypto_read_handler.h
@@ -0,0 +1,64 @@
+// 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_CRYPTO_DEFAULT_DEFAULT_CRYPTO_READ_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_DEFAULT_CRYPTO_READ_HANDLER_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/crypto_read_handler.h"
+
+namespace credentio {
+
+struct DefaultCryptoReadHandlerOptions {
+  // The content of the PEM file containing claim signer trust anchors,
+  // trusted for claim signer certs containing the `c2pa-kp-claimSigning` EKU.
+  //
+  // At least one of `claim_signer_trust_anchors_pem` or
+  // `legacy_claim_signer_trust_anchors_pem` must be nonempty unless
+  // `skip_claim_signer_trust_checks_for_test` is true.
+  absl::string_view claim_signer_trust_anchors_pem;
+
+  // The content of the PEM file containing legacy claim signer trust anchors,
+  // trusted for claim signer certs containing any of the EKUs
+  // `id-kp-emailProtection`, `id-kp-documentSigning`, and
+  // `c2pa-kp-claimSigning`.
+  absl::string_view legacy_claim_signer_trust_anchors_pem;
+
+  // The content of the PEM file containing TSA trust anchors.
+  // Required unless `skip_tsa_trust_checks` is true.
+  absl::string_view tsa_trust_anchors_pem;
+
+  // Skips claim signer trust checks.
+  bool skip_claim_signer_trust_checks_for_test = false;
+
+  // Skips timestamp trust checks.
+  bool skip_tsa_trust_checks_for_test = false;
+
+  // The trust level of the handler.
+  CryptoReadHandler::TrustEnvironment trust_environment =
+      CryptoReadHandler::TrustEnvironment::kUnspecified;
+};
+
+// Creates an implementation of CryptoReadHandler intended for use in
+// production. It uses Tink, BoringSSL, and SimpleCmsParser.
+absl::StatusOr<std::unique_ptr<CryptoReadHandler>>
+CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_DEFAULT_CRYPTO_READ_HANDLER_H_
diff --git a/crypto/default/eku_verifier.cc b/crypto/default/eku_verifier.cc
new file mode 100644
index 0000000..0bb1808
--- /dev/null
+++ b/crypto/default/eku_verifier.cc
@@ -0,0 +1,114 @@
+// 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 "crypto/default/eku_verifier.h"
+
+#include "absl/strings/string_view.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/bio.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/x509.h"
+
+namespace credentio {
+namespace {
+
+constexpr absl::string_view kAnyEkuOid = "2.5.29.37.0";
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_trust_lists.
+constexpr absl::string_view kC2paClaimSigningEkuOid = "1.3.6.1.4.1.62558.2.1";
+// The maximum number of EKU values that can be present in a certificate.
+// We have this upper limit to prevent malicious certificates with arbitrarily
+// large EKU lists to be processed by the check.
+constexpr size_t kMaxEkuCount = 100;
+// The bit index of the `digitalSignature` KU bit in the keyUsage bit string.
+constexpr int kDigitalSignatureKeyUsageBit = 0;
+
+}  // namespace
+
+bool EkuVerifier::CheckLeafCertEku(absl::string_view leaf_cert_der) {
+  const unsigned char* leaf_cert_der_bytes =
+      reinterpret_cast<const unsigned char*>(leaf_cert_der.data());
+  bssl::UniquePtr<BIO> bio(
+      BIO_new_mem_buf(leaf_cert_der_bytes, leaf_cert_der.size()));
+  if (bio == nullptr) {
+    return false;
+  }
+  bssl::UniquePtr<X509> leaf_cert(d2i_X509_bio(bio.get(), /*x509=*/nullptr));
+  if (leaf_cert == nullptr) {
+    return false;
+  }
+
+  // Check that the certificate asserts the `digitalSignature` KU bit per
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_requirements.
+  bssl::UniquePtr<ASN1_BIT_STRING> key_usage(static_cast<ASN1_BIT_STRING*>(
+      X509_get_ext_d2i(leaf_cert.get(), NID_key_usage, /*out_critical=*/nullptr,
+                       /*out_idx=*/nullptr)));
+  if (key_usage == nullptr) {
+    return false;
+  }
+  if (ASN1_BIT_STRING_get_bit(key_usage.get(), kDigitalSignatureKeyUsageBit) ==
+      0) {
+    return false;
+  }
+
+  int loc_eku =
+      X509_get_ext_by_NID(leaf_cert.get(), NID_ext_key_usage, /*lastpos=*/-1);
+  if (loc_eku < 0) {
+    return false;
+  }
+  X509_EXTENSION* ext = X509_get_ext(leaf_cert.get(), loc_eku);
+  if (!ext) {
+    return false;
+  }
+  bssl::UniquePtr<EXTENDED_KEY_USAGE> eku(
+      (EXTENDED_KEY_USAGE*)X509V3_EXT_d2i(ext));
+  if (!eku) {
+    return false;
+  }
+  bssl::UniquePtr<ASN1_OBJECT> target_oid(
+      OBJ_txt2obj(kC2paClaimSigningEkuOid.data(), 0));
+  if (!target_oid) {
+    return false;
+  }
+  bssl::UniquePtr<ASN1_OBJECT> any_eku_oid(OBJ_txt2obj(kAnyEkuOid.data(), 0));
+  if (!any_eku_oid) {
+    return false;
+  }
+  bool found = false;
+  bool any_eku_found = false;
+  int num_usages = sk_ASN1_OBJECT_num(eku.get());
+  if (num_usages > kMaxEkuCount) {
+    return false;
+  }
+  for (int i = 0; i < num_usages; ++i) {
+    ASN1_OBJECT* obj = sk_ASN1_OBJECT_value(eku.get(), i);
+    if (!obj) {
+      continue;
+    }
+    if (OBJ_cmp(obj, target_oid.get()) == 0) {
+      // The C2PA claim signing EKU is present
+      found = true;
+    }
+    if (OBJ_cmp(obj, any_eku_oid.get()) == 0) {
+      // The `anyExtendedKeyUsage` EKU is present
+      any_eku_found = true;
+    }
+  }
+  return found && !any_eku_found;
+}
+
+}  // namespace credentio
diff --git a/crypto/default/eku_verifier.h b/crypto/default/eku_verifier.h
new file mode 100644
index 0000000..492985a
--- /dev/null
+++ b/crypto/default/eku_verifier.h
@@ -0,0 +1,48 @@
+// 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_CRYPTO_DEFAULT_EKU_VERIFIER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_EKU_VERIFIER_H_
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+class EkuVerifier {
+ public:
+  EkuVerifier() = default;
+  ~EkuVerifier() = default;
+
+  // Performs the EKU checks described in
+  // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_general_requirements
+  // for claim signing certificates. Returns true if the leaf certificate
+  // asserts the `digitalSignature` key usage bit, it has the C2PA claim signing
+  // EKU and the `anyExtendedKeyUsage` EKU is not present. Returns false
+  // otherwise (including for parsing errors). If the certificate is malformed,
+  // the verification that will later be performed by BoringSSL will fail to
+  // handle the malformed certificate.
+
+  // This additional check is needed because the corresponding BoringSSL
+  // verification option
+  // (`bssl::CertificateVerifyOptions::KeyPurpose::C2PA_MANIFEST`) used by the
+  // `DefaultParsedCertificates:VerifyClaimSignerTrust` does not check the
+  // requirements of the most recent version of the spec (2.2). BoringSSL can
+  // only be used to check the requirements of the version 2.1.
+  static bool CheckLeafCertEku(absl::string_view leaf_cert_der);
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_EKU_VERIFIER_H_
diff --git a/crypto/default/eku_verifier_test.cc b/crypto/default/eku_verifier_test.cc
new file mode 100644
index 0000000..328795f
--- /dev/null
+++ b/crypto/default/eku_verifier_test.cc
@@ -0,0 +1,139 @@
+// 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 "crypto/default/eku_verifier.h"
+
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/string_view.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+// This certificate is self-signed and has no EKU.
+// The certificate is created by following the steps below:
+// 1. Create a configuration file called `cert.cnf` with the following content:
+//
+// [ req ]
+// distinguished_name = req_distinguished_name
+// prompt = no
+//
+// [ req_distinguished_name ]
+// C = US
+// ST = MA
+// L = Cambridge
+// O = Unit Test
+// CN = Unit Test
+//
+// [ v3_ext ]
+// basicConstraints = CA:false
+// keyUsage = digitalSignature, keyEncipherment
+//
+// 2. Generate the self-signed certificate by running the following command:
+// openssl req -x509 -new -newkey rsa:2048 -keyout mycert.key -out mycert.crt \
+// -days 365 -config cert.cnf -extensions v3_ext -nodes
+//
+// 3. Generate the DER bytes of the certificate by running the following
+// command:
+// openssl req -x509 -new -newkey rsa:2048 -keyout mycert.key -out mycert.der \
+// -days 365 -config cert.cnf -extensions v3_ext -nodes -outform DER
+//
+// 4. Encode the DER bytes to base64 by running the following command:
+// cat mycert.der | base64 -w 0
+constexpr absl::string_view kSelfSignedCertNoEkuBase64 =
+    R"(MIIC+TCCAeGgAwIBAgIUYSYg4BtYfbjcgKpPxDjiPGSmolMwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNTA4MDkxNzA5MzhaFw0yNjA4MDkxNzA5MzhaMBcxFTATBgNVBAMMDG15c2VydmVyLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPNaCaGamYc4i98Jqzkvi3+RW1cDQnLeWfxayJmo2f821iphgT+SnndzGqgzKnBDqoyxzu4oAOPxvm2AB/IVnnpth4UHYCvLkjYH5KLAWGCy3cJ+9IiN5XqLBB1gi4hert4kZfPRsZkLuCLiNPnFx3De9JRqd+S9kPcGkXZS2WIdjkk4MK9fmy2RsCUsvywQCDmmoEUpyhB7cj1ZiML4JH46wXRwQdoHGa1DGde13suhtCnf2QfFvVnP+SEGbhHkGAydwWi30YJojzQTEpI8wZa1oTMbXgfYZ13AObE2g2N84BVfk4CUivMI0Agrdx4Ix1nuPqPqwh7zyx6rc49kt+kCAwEAAaNCMEAwHQYDVR0OBBYEFEkKD+6lAuhRcKIs3BqU1VA12nB8MB8GA1UdIwQYMBaAFBdH5e5xEcv9yEjPOO2+XNgncNkXMA0GCSqGSIb3DQEBCwUAA4IBAQAelWiUlQsMtdZRn5OfwnQC1p5jlbni+HMYofZRWmFqNrFqVURxHZmtfN6bqI0gNARVKJzTy/y8g006603zPBIu4Ax715TSbhEhPsnOucB6b0ydvA6ynYm3n/QLsWeT9ummbbWJjITkhzHmu4t8tHyKMXOnX8LPTi0ZBnZ/Jw6Lb+7kM0X1at7KfhBbAqcCiIsQLS7zURehijoAxcJWdC8OQZZ/3HeqWzuAH52jHrF/g/H6r15JzXCyPVSbBey4GbPh/+1MHe8RQyMmCGyq1gEk1K51XGKjepLoNgxUs4l10lMpGFptEHph+bZfrVKgfi4okq/D5EPdje6N5sCSZL6Q)";
+// This certificate is self-signed and it has both the C2PA claim signing EKU
+// and the `anyExtendedKeyUsage` EKU.
+// The certificate is created by following the steps above for the
+// `kSelfSignedCertNoEkuBase64` with the following change:
+// 1. In the configuration file `cert.cnf`, add the line to the [ v3_ext ]
+// section:
+// extendedKeyUsage = 2.5.29.37.0, 1.3.6.1.4.1.62558.2.1
+constexpr absl::string_view kSelfSignedCertWithAnyEkuAndC2paEkuBase64 =
+    R"(MIIDkDCCAnigAwIBAgIUFtIA85O7PUfkXi1HpHaUHhci8DEwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MB4XDTI1MDgwOTE3NDI1MVoXDTI2MDgwOTE3NDI1MVowVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmR3ZSWgPSGo2W6z/JVyV7CHVhYv0BGEkMCfL0vFiHt2ThYQZCQeHV+cd4++acnPuPwvnoZxWPbTemJ7D9bMbWeuQ6XKSrV4nUx+2ZFi/CcwqqdawoiSThTW/Rw48qXacHfGRiM2r1tt7nmJ7gMUcM6kwXeHr1VabiIZqy0kW9+h71UqMW22ntGilfESsZq2QududiWB4ivLnSIFJWHTpGaNSsMdA4AwVV4cfpmf3dyj88u4CrV51k9T2q0ARocgXLVZdv6KoC6MAqvNykRg7qQGlH6nWgYW0DRGu9EHoqaB9JnkSWBkkrBewkTePeRL3eLRmUXrNJV4r8tuL20uq4QIDAQABo1YwVDAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDAbBgNVHSUEFDASBgRVHSUABgorBgEEAYPoXgIBMB0GA1UdDgQWBBRVzemKLsqLTYmfYVynHKW2PD3UPTANBgkqhkiG9w0BAQsFAAOCAQEARgFgmk4rUx8xcZQsP7RS16/3tMZvPZ59M8yS+v7y/sHN8lim3jJBtBN0OE/rFQzzH93gkPo0slJzD28GqIV495GyJSi4iOsreGa2j9kqrVGcP8BXTJYYyN5mAHixRq7XgTpjQuJ+d9nFXJo9g3Yy4OrV/SIC1poXb++YX4hdDmwCmoSnutiJQJbGBw1lx2XHaQtrMZFtrsxRUbgOZhMKlaFyBXEJZs4V8oSOH24iiK/qIo2DNUwmOuZngJXfEFB1W8cRPu1gM25wX6uxUjo0Jf/j7ooBNrnC5HFj700RR5jYjau1W6ZilXK41nHo3BsIdmNZLPmwBWCp4zumt9XV4g==)";
+// This certificate is self-signed and it only has the C2PA claim signing EKU.
+// The certificate is created by following the steps above for the
+// `kSelfSignedCertNoEkuBase64` with the following change:
+// 1. In the configuration file `cert.cnf`, add the line to the [ v3_ext ]
+// section:
+// extendedKeyUsage = 1.3.6.1.4.1.62558.2.1
+constexpr absl::string_view kSelfSignedCertWithC2paEkuBase64 =
+    R"(MIIDijCCAnKgAwIBAgIUbvfHtjDh0li8c7UsrlX+7HZANhkwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MB4XDTI1MDgwOTE3NDUyNloXDTI2MDgwOTE3NDUyNlowVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlohRQoQJeWRV1A6W70Xmaq+SV2ftqGIW+dj9u/JccI/1WI6sXk2hIJPp5LvKDoyiN3gA+cgaTIhiJ2BbnJzFkzAKZhV2KdQyD2OOHefgycRc18Rkg3zJlylubV7/O2kfZsZIwUByqPi/KlMtWwkLTnYDnZ8VvifKSvCVDLA1m6nkhkBCaVlHXEtlCIzYYcbYXX31HsrKpMhYBbRcGaqbWAkSLg3qyovwDyjEKVWRxswUYyfi4IJgB+Fld27YSSBslEHhH2WaTWLIInnNxkR/gzw3fMkNMJTH/l4jUsOR6Kl/9ofzJg371hPp7MKuY37JPyVOFi9l+jw0Gk41eakICwIDAQABo1AwTjAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMB0GA1UdDgQWBBTBfCBm97Ba5p/5yjlhNqcesYD2VDANBgkqhkiG9w0BAQsFAAOCAQEAfBwfVUAXh6ZLEtWV4Nc9IqpL6hkF4HJhkPlxPVcUH6nsdBUkr9tGbEBtrg4FyqwbVfsAr6geZK/UZ/fT6iltwU8qQB0+LbT1wvjPgPgVapwMioh8whyNUOHuT3P1DtaJ/ma507Da2osdqk3n9+J+Gc7TUVupetfoDhOowgjhqI8oPrqIUki/arS7T6QfiNlHK0f1/l+nhLaaC4W84l+jCZn54GcZh39aw+I+oG/JhVG+k8bQ+6STG/WiPlXP9+2f8Ufh5U6srUkN0zQ3CEI+jpuvCH+Kco7CTcLjtWeDDOMyr/H7Sgy9gWN1UsdOTjIiJiqM2R9AGJ7Qki1ug8JwuA==)";
+// This certificate is self-signed and it has 109 EKU values.
+// The certificate is created by following the steps above for the
+// `kSelfSignedCertNoEkuBase64` with the following change:
+// 1. In the configuration file `cert.cnf`, add the line to the [ v3_ext ]
+// section:
+// extendedKeyUsage = 1.3.6.1.4.1.62558.2.1, 1.3.6.1.4.1.62558.2.2,
+// ..., 1.3.6.1.4.1.62558.2.109
+constexpr absl::string_view kSelfSignedCertWithLargeEkuBase64 =
+    R"(MIIIpDCCB4ygAwIBAgIUSeQijsSqcxpRYnFjxo0nHwCVuN4wDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MB4XDTI1MDgwOTE4NTQ0MVoXDTI2MDgwOTE4NTQ0MVowVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsELqKytAjSQmygv+le4WsIAPNL3653/fsHZM4Sxx6ytJRK3fg8s55sd6SQJpLHtjzJh1CgkjjOkVGMHZHCP9F5/rYNosRpN3dvljbOGpoRLv0uSJRbDQVe5/3pdejLutjFfV4GRwWj0eYj1NShBP17bTO1NhCmWvyYb17KoV8ynprDvix4eYnYm/39jQUM0k4QpZDW+OaWMzjqC6hRrHLEU/pAXKY9H4zwM4O1R4dNWrMT92PhwxlaGvcUHNK8bVb61QjVmNTaPNnMY5udJkQpY6kdIMBzWoHB0gzKDVrqpvS0tIgy30IpKipHKNFya/emcICZ6eCzz20S9xCCXwpwIDAQABo4IFaDCCBWQwCQYDVR0TBAIwADALBgNVHQ8EBAMCBaAwggUpBgNVHSUEggUgMIIFHAYKKwYBBAGD6F4CAQYKKwYBBAGD6F4CAgYKKwYBBAGD6F4CAwYKKwYBBAGD6F4CBAYKKwYBBAGD6F4CBQYKKwYBBAGD6F4CBgYKKwYBBAGD6F4CBwYKKwYBBAGD6F4CCAYKKwYBBAGD6F4CCQYKKwYBBAGD6F4CCgYKKwYBBAGD6F4CCwYKKwYBBAGD6F4CDAYKKwYBBAGD6F4CDQYKKwYBBAGD6F4CDgYKKwYBBAGD6F4CDwYKKwYBBAGD6F4CEAYKKwYBBAGD6F4CEQYKKwYBBAGD6F4CEgYKKwYBBAGD6F4CEwYKKwYBBAGD6F4CFAYKKwYBBAGD6F4CFQYKKwYBBAGD6F4CFgYKKwYBBAGD6F4CFwYKKwYBBAGD6F4CGAYKKwYBBAGD6F4CGQYKKwYBBAGD6F4CGgYKKwYBBAGD6F4CGwYKKwYBBAGD6F4CHAYKKwYBBAGD6F4CHQYKKwYBBAGD6F4CHgYKKwYBBAGD6F4CHwYKKwYBBAGD6F4CIAYKKwYBBAGD6F4CIQYKKwYBBAGD6F4CIgYKKwYBBAGD6F4CIwYKKwYBBAGD6F4CJAYKKwYBBAGD6F4CJQYKKwYBBAGD6F4CJgYKKwYBBAGD6F4CJwYKKwYBBAGD6F4CKAYKKwYBBAGD6F4CKQYKKwYBBAGD6F4CKgYKKwYBBAGD6F4CKwYKKwYBBAGD6F4CLAYKKwYBBAGD6F4CLQYKKwYBBAGD6F4CLgYKKwYBBAGD6F4CLwYKKwYBBAGD6F4CMAYKKwYBBAGD6F4CMQYKKwYBBAGD6F4CMgYKKwYBBAGD6F4CMwYKKwYBBAGD6F4CNAYKKwYBBAGD6F4CNQYKKwYBBAGD6F4CNgYKKwYBBAGD6F4CNwYKKwYBBAGD6F4COAYKKwYBBAGD6F4COQYKKwYBBAGD6F4COgYKKwYBBAGD6F4COwYKKwYBBAGD6F4CPAYKKwYBBAGD6F4CPQYKKwYBBAGD6F4CPgYKKwYBBAGD6F4CPwYKKwYBBAGD6F4CQAYKKwYBBAGD6F4CQQYKKwYBBAGD6F4CQgYKKwYBBAGD6F4CQwYKKwYBBAGD6F4CRAYKKwYBBAGD6F4CRQYKKwYBBAGD6F4CRgYKKwYBBAGD6F4CRwYKKwYBBAGD6F4CSAYKKwYBBAGD6F4CSQYKKwYBBAGD6F4CSgYKKwYBBAGD6F4CSwYKKwYBBAGD6F4CTAYKKwYBBAGD6F4CTQYKKwYBBAGD6F4CTgYKKwYBBAGD6F4CTwYKKwYBBAGD6F4CUAYKKwYBBAGD6F4CUQYKKwYBBAGD6F4CUgYKKwYBBAGD6F4CUwYKKwYBBAGD6F4CVAYKKwYBBAGD6F4CVQYKKwYBBAGD6F4CVgYKKwYBBAGD6F4CVwYKKwYBBAGD6F4CWAYKKwYBBAGD6F4CWQYKKwYBBAGD6F4CWgYKKwYBBAGD6F4CWwYKKwYBBAGD6F4CXAYKKwYBBAGD6F4CXQYKKwYBBAGD6F4CXgYKKwYBBAGD6F4CXwYKKwYBBAGD6F4CYAYKKwYBBAGD6F4CYQYKKwYBBAGD6F4CYgYKKwYBBAGD6F4CYwYKKwYBBAGD6F4CZAYKKwYBBAGD6F4CZQYKKwYBBAGD6F4CZgYKKwYBBAGD6F4CZwYKKwYBBAGD6F4CaAYKKwYBBAGD6F4CaQYKKwYBBAGD6F4CagYKKwYBBAGD6F4CawYKKwYBBAGD6F4CbAYKKwYBBAGD6F4CbTAdBgNVHQ4EFgQUm/QVqHjDI3Qh2JRa7gPCEQedFZIwDQYJKoZIhvcNAQELBQADggEBALAlt2egdhqsUcrrFAjGfk8wo7g4BwMXpZrCZPM7aEjzkoIDygHjiYeMyHPIgBizYDukxa+0arWF9OSW+nVyboPZgz2JqLAGIagCwqjxfvneEaU2aLONxJX2om8INg5yXdTkp+ZbtchhIACyzfbu2YOoGIEaldIgB9cw2Wzo8UYTkmLIlnPkciomYwjYjObnfF1oyDlGhIqWLHb6r5DzhA2bkMwuvR/AjRicJHXevl02nAR9B3YVZ8XhhQAxi5thVFsy4phYQFA1uhL6+YczRY/0cob8x2JAS21WlyCyucPrqzCESCitK8n1t9Kr8oN33hZQ05QSz2q4F4GqSVZrLuI=)";
+// This certificate is self-signed. It has the C2PA claim signing EKU but does
+// not assert the `digitalSignature` KU bit. The certificate is created by
+// following the steps above for the `kSelfSignedCertNoEkuBase64` with the
+// following changes:
+// 1. In the configuration file `cert.cnf`, add the line to the [ v3_ext ]
+// section:
+// extendedKeyUsage = 1.3.6.1.4.1.62558.2.1
+// 2. In the same [v3_ext] section, update the line for the `keyUsage` extension
+// as follows:
+// keyUsage = keyEncipherment
+constexpr absl::string_view kSelfSignedCertWithoutDigitalSignatureKuBase64 =
+    R"(MIIDijCCAnKgAwIBAgIULLgHRj+25oLuEWteEKtmNySP7SYwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MB4XDTI1MDgxMTE1NTc1OVoXDTI2MDgxMTE1NTc1OVowVjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1BMRIwEAYDVQQHDAlDYW1icmlkZ2UxEjAQBgNVBAoMCVVuaXQgVGVzdDESMBAGA1UEAwwJVW5pdCBUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtTI0nTndMHK9cImhhfWaXa4k/6bHwyQk0S71ic+ieibdBFV4mzYbDPZ2u84Z80hFAlyet725PB8j9hJiaT8eG4Tacw19rUN1CimoMb7SJ9a1qshcmNqxNJcfLBmkntzdVwgwxqGtkS66LOCJ2SHqREkrzqBZMB8OCAsf/gbS5vgY/3b5h6Xcdw1B1aIoKtRz8A8y5IK1DZF/0vzs0IqWjXlr5A4znEmdV1p18tcnL920QWim/winTyFXOj/IO+IK8tF1atjbS1RZChmQnN0bGJbc/8hmH5qpy1qDS9qS8sHK2BKTphPpqE5Zjrrb5S+4HUFUQNGxZSve9ghd7vj08wIDAQABo1AwTjAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFIDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMB0GA1UdDgQWBBQAJkqyo/wRZ7N+SfmmbE/9WOuZszANBgkqhkiG9w0BAQsFAAOCAQEAa15O9d799osT9m8DfS1acQ0PYlhqpgLeeWuKWk/5KkJbNr8nI6SIOmyzXZ/aTvrU+EDkDWVJG8QGN+hRN2J1zhWTJ5zefpkfkXvdUT8Io/TVDkDT/EBzFOC865+yTe9pxcTcS5RjXLC6hCn4Rt37haeBe+dmBESflUpJMql+lkXgIuzRc6U7atb3NcdC2biKSt1FgG07Z4UqjbmwkpwU9nkaoioGZ+qEWEThoKbCdBSeaINP94/QPBnZiJPx0gqqc6v++6gZ5wnYRA3DxT00+5v3WfGzscfq5t5J0hW1dqXWE8svOB/lNmo2Zjz17EO8tqUEeHZgoNeE72ZCTpbo3Q==)";
+
+std::string Base64Decode(absl::string_view base64) {
+  std::string decoded;
+  CHECK(absl::Base64Unescape(base64, &decoded));
+  return decoded;
+}
+
+TEST(EkuVerifierTest, CheckLeafCertEku_EmptyCert) {
+  EXPECT_FALSE(EkuVerifier::CheckLeafCertEku(""));
+}
+
+TEST(EkuVerifierTest, CheckLeafCertEku_MalformedCert) {
+  EXPECT_FALSE(EkuVerifier::CheckLeafCertEku("malformed cert"));
+}
+
+TEST(EkuVerifierTest, CheckLeafCertEku_NoEku) {
+  EXPECT_FALSE(
+      EkuVerifier::CheckLeafCertEku(Base64Decode(kSelfSignedCertNoEkuBase64)));
+}
+
+TEST(EkuVerifierTest, CheckLeafCertEku_C2paEkuAndAnyEku) {
+  EXPECT_FALSE(EkuVerifier::CheckLeafCertEku(
+      Base64Decode(kSelfSignedCertWithAnyEkuAndC2paEkuBase64)));
+}
+
+TEST(EkuVerifierTest, CheckLeafCertEku_C2paEkuOnly) {
+  EXPECT_TRUE(EkuVerifier::CheckLeafCertEku(
+      Base64Decode(kSelfSignedCertWithC2paEkuBase64)));
+}
+
+TEST(EkuVerifierTest, CheckLeafCertEku_LargeEku) {
+  EXPECT_FALSE(EkuVerifier::CheckLeafCertEku(
+      Base64Decode(kSelfSignedCertWithLargeEkuBase64)));
+}
+
+TEST(EkuVerifierTest, CheckLeafCertEku_WithoutDigitalSignatureKu) {
+  EXPECT_FALSE(EkuVerifier::CheckLeafCertEku(
+      Base64Decode(kSelfSignedCertWithoutDigitalSignatureKuBase64)));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/default/hasher.cc b/crypto/default/hasher.cc
new file mode 100644
index 0000000..4e7b920
--- /dev/null
+++ b/crypto/default/hasher.cc
@@ -0,0 +1,155 @@
+// 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 "crypto/default/hasher.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/base/no_destructor.h"
+#include "absl/base/nullability.h"
+#include "absl/log/absl_log.h"
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+#include "openssl/base.h"
+#include "openssl/digest.h"
+
+namespace credentio {
+namespace {
+
+class DefaultHashChecker : public HashChecker {
+ public:
+  explicit DefaultHashChecker(std::unique_ptr<Hasher> hasher)
+      : hasher_(std::move(hasher)) {}
+
+  void Update(absl::string_view content) override { hasher_->Update(content); }
+
+  bool Check(absl::string_view expected_hash) override {
+    std::string computed_hash = hasher_->Digest();
+    if (computed_hash != expected_hash) {
+      ABSL_DVLOG(1) << "Hash mismatch: base64(computed) = "
+                    << absl::Base64Escape(computed_hash)
+                    << " base64(expected) = "
+                    << absl::Base64Escape(expected_hash);
+      return false;
+    }
+    return true;
+  }
+
+ private:
+  std::unique_ptr<Hasher> hasher_;
+};
+
+class DefaultHashCheckerFactoryImpl : public HashCheckerFactory {
+ public:
+  DefaultHashCheckerFactoryImpl() = default;
+
+  absl::StatusOr<std::unique_ptr<HashChecker>> Create(
+      HashAlgorithm algorithm) const override {
+    ABSL_ASSIGN_OR_RETURN(auto hasher, CreateHasher(algorithm));
+    return std::make_unique<DefaultHashChecker>(std::move(hasher));
+  }
+};
+
+class DefaultHasher : public Hasher {
+ public:
+  static absl::StatusOr<std::unique_ptr<DefaultHasher>> Create(
+      const EVP_MD* md) {
+    bssl::UniquePtr<EVP_MD_CTX> ctx(EVP_MD_CTX_new());
+    if (ctx == nullptr || EVP_DigestInit_ex(ctx.get(), md, nullptr) != 1) {
+      return absl::InternalError("Failed to initialize hasher");
+    }
+    return std::unique_ptr<DefaultHasher>(new DefaultHasher(std::move(ctx)));
+  }
+
+  void Update(absl::string_view content) override {
+    // We don't check the return value because BoringSSL's implementation always
+    // returns 1.
+    EVP_DigestUpdate(ctx_.get(), content.data(), content.size());
+  }
+
+  std::string Digest() override {
+    unsigned int len = 0;
+    unsigned char digest[EVP_MAX_MD_SIZE];
+    // We don't check the return value because BoringSSL's implementation always
+    // returns 1.
+    EVP_DigestFinal_ex(ctx_.get(), digest, &len);
+    return std::string(reinterpret_cast<char*>(digest), len);
+  }
+
+ private:
+  explicit DefaultHasher(bssl::UniquePtr<EVP_MD_CTX> ctx)
+      : ctx_(std::move(ctx)) {}
+
+  bssl::UniquePtr<EVP_MD_CTX> ctx_;
+};
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<Hasher> absl_nonnull> CreateHasher(
+    HashAlgorithm algorithm) {
+  switch (algorithm) {
+    case HashAlgorithm::kSha256:
+      return DefaultHasher::Create(EVP_sha256());
+    case HashAlgorithm::kSha384:
+      return DefaultHasher::Create(EVP_sha384());
+    case HashAlgorithm::kSha512:
+      return DefaultHasher::Create(EVP_sha512());
+  }
+  std::string error_msg = absl::StrCat(
+      "fell through end of exhaustive switch statement with HashAlgorithm "
+      "value: ",
+      static_cast<int>(algorithm));
+  ABSL_LOG(DFATAL) << error_msg;
+  return absl::FailedPreconditionError(error_msg);
+}
+
+absl::StatusOr<std::unique_ptr<Hasher> absl_nonnull> CreateHasher(
+    absl::string_view algorithm) {
+  ABSL_ASSIGN_OR_RETURN(auto ha, ParseHashAlgorithm(algorithm));
+  return CreateHasher(ha);
+}
+
+const HashCheckerFactory& DefaultHashCheckerFactory() {
+  static const absl::NoDestructor<DefaultHashCheckerFactoryImpl> singleton;
+  return *singleton;
+}
+
+const HasherFactory& DefaultHasherFactory() {
+  static const absl::NoDestructor<Sha256HasherFactory> singleton;
+  return *singleton;
+}
+
+absl::StatusOr<std::unique_ptr<Hasher>> Sha256HasherFactory::Create() const {
+  return DefaultHasher::Create(EVP_sha256());
+}
+
+absl::StatusOr<std::unique_ptr<Hasher>> Sha384HasherFactory::Create() const {
+  return DefaultHasher::Create(EVP_sha384());
+}
+
+absl::StatusOr<std::unique_ptr<Hasher>> Sha512HasherFactory::Create() const {
+  return DefaultHasher::Create(EVP_sha512());
+}
+
+}  // namespace credentio
diff --git a/crypto/default/hasher.h b/crypto/default/hasher.h
new file mode 100644
index 0000000..025a0f6
--- /dev/null
+++ b/crypto/default/hasher.h
@@ -0,0 +1,64 @@
+// 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_CRYPTO_DEFAULT_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_HASHER_H_
+
+#include <memory>
+
+#include "absl/base/nullability.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+
+namespace credentio {
+
+// Returns a `Hasher` for `algorithm` or error if the algorithm is not
+// supported. The returned hasher uses the default OpenSSL implementation.
+absl::StatusOr<std::unique_ptr<Hasher> absl_nonnull> CreateHasher(
+    HashAlgorithm algorithm);
+
+// Returns a `Hasher`, or error if the algorithm is not supported.
+absl::StatusOr<std::unique_ptr<Hasher> absl_nonnull> CreateHasher(
+    absl::string_view algorithm);
+
+// Returns the default `HashCheckerFactory`.
+const HashCheckerFactory& DefaultHashCheckerFactory();
+
+// Returns the default `HasherFactory`.
+const HasherFactory& DefaultHasherFactory();
+
+class Sha256HasherFactory : public HasherFactory {
+ public:
+  absl::StatusOr<std::unique_ptr<Hasher>> Create() const override;
+  HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; }
+};
+
+class Sha384HasherFactory : public HasherFactory {
+ public:
+  absl::StatusOr<std::unique_ptr<Hasher>> Create() const override;
+  HashAlgorithm algorithm() const override { return HashAlgorithm::kSha384; }
+};
+
+class Sha512HasherFactory : public HasherFactory {
+ public:
+  absl::StatusOr<std::unique_ptr<Hasher>> Create() const override;
+  HashAlgorithm algorithm() const override { return HashAlgorithm::kSha512; }
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_HASHER_H_
diff --git a/crypto/default/hasher_test.cc b/crypto/default/hasher_test.cc
new file mode 100644
index 0000000..542afa4
--- /dev/null
+++ b/crypto/default/hasher_test.cc
@@ -0,0 +1,167 @@
+// 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 "crypto/default/hasher.h"
+
+#include <string>
+#include <utility>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+using ::testing::IsFalse;
+using ::testing::IsTrue;
+using ::testing::NotNull;
+
+std::string BytesFromHexOrDie(absl::string_view hex) {
+  std::string bytes;
+  CHECK(absl::HexStringToBytes(hex, &bytes));
+  return bytes;
+}
+
+TEST(CreateHasherTest, SHA256) {
+  auto hasher_or = CreateHasher("sha256");
+  ASSERT_TRUE(hasher_or.ok());
+  auto hasher = std::move(hasher_or).value();
+  hasher->Update(absl::string_view("hello"));
+  hasher->Update(absl::string_view("world"));
+  EXPECT_THAT(
+      absl::BytesToHexString(hasher->Digest()),
+      "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af");
+}
+
+TEST(CreateHasherTest, SHA384) {
+  auto hasher_or = CreateHasher("sha384");
+  ASSERT_TRUE(hasher_or.ok());
+  auto hasher = std::move(hasher_or).value();
+  hasher->Update(absl::string_view("hello"));
+  hasher->Update(absl::string_view("world"));
+  EXPECT_THAT(absl::BytesToHexString(hasher->Digest()),
+              "97982a5b1414b9078103a1c008c4e3526c27b41cdbcf80790560a40f2a9bf2ed"
+              "4427ab1428789915ed4b3dc07c454bd9");
+}
+
+TEST(CreateHasherTest, SHA512) {
+  auto hasher_or = CreateHasher("sha512");
+  ASSERT_TRUE(hasher_or.ok());
+  auto hasher = std::move(hasher_or).value();
+  hasher->Update(absl::string_view("hello"));
+  hasher->Update(absl::string_view("world"));
+  EXPECT_THAT(
+      absl::BytesToHexString(hasher->Digest()),
+      "1594244d52f2d8c12b142bb61f47bc2eaf503d6d9ca8480cae9fcf112f66e4967dc5e8fa"
+      "98285e36db8af1b8ffa8b84cb15e0fbcf836c3deb803c13f37659a60");
+}
+
+TEST(CreateHasherTest, UnknownAlgorithm) {
+  EXPECT_THAT(CreateHasher("blah"),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "unrecognized hash algorithm name"));
+}
+
+TEST(HashCheckerTest, Valid) {
+  auto checker_or = DefaultHashCheckerFactory().Create(HashAlgorithm::kSha256);
+  ASSERT_TRUE(checker_or.ok());
+  auto checker = std::move(checker_or).value();
+  ASSERT_THAT(checker, NotNull());
+  checker->Update("hello");
+  checker->Update("world");
+  EXPECT_THAT(
+      checker->Check(BytesFromHexOrDie(
+          "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af")),
+      IsTrue());
+}
+
+TEST(HashCheckerTest, Invalid) {
+  auto checker_or = DefaultHashCheckerFactory().Create(HashAlgorithm::kSha256);
+  ASSERT_TRUE(checker_or.ok());
+  auto checker = std::move(checker_or).value();
+  ASSERT_THAT(checker, NotNull());
+  checker->Update("hello");
+  checker->Update("world2");
+  EXPECT_THAT(
+      checker->Check(BytesFromHexOrDie(
+          "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af")),
+      IsFalse());
+}
+
+TEST(CreateHasherTest, SHA256HashFactory) {
+  Sha256HasherFactory factory;
+  EXPECT_EQ(factory.algorithm(), HashAlgorithm::kSha256);
+
+  auto hasher_or = factory.Create();
+  ASSERT_TRUE(hasher_or.ok());
+  auto hasher = std::move(hasher_or).value();
+  hasher->Update(absl::string_view("hello"));
+  hasher->Update(absl::string_view("world"));
+  EXPECT_THAT(
+      absl::BytesToHexString(hasher->Digest()),
+      "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af");
+}
+
+TEST(CreateHasherTest, SHA384HashFactory) {
+  Sha384HasherFactory factory;
+  EXPECT_EQ(factory.algorithm(), HashAlgorithm::kSha384);
+
+  auto hasher_or = factory.Create();
+  ASSERT_TRUE(hasher_or.ok());
+  auto hasher = std::move(hasher_or).value();
+  hasher->Update(absl::string_view("hello"));
+  hasher->Update(absl::string_view("world"));
+  EXPECT_THAT(absl::BytesToHexString(hasher->Digest()),
+              "97982a5b1414b9078103a1c008c4e3526c27b41cdbcf80790560a40f2a9bf2ed"
+              "4427ab1428789915ed4b3dc07c454bd9");
+}
+
+TEST(CreateHasherTest, SHA512HashFactory) {
+  Sha512HasherFactory factory;
+  EXPECT_EQ(factory.algorithm(), HashAlgorithm::kSha512);
+
+  auto hasher_or = factory.Create();
+  ASSERT_TRUE(hasher_or.ok());
+  auto hasher = std::move(hasher_or).value();
+  hasher->Update(absl::string_view("hello"));
+  hasher->Update(absl::string_view("world"));
+  EXPECT_THAT(
+      absl::BytesToHexString(hasher->Digest()),
+      "1594244d52f2d8c12b142bb61f47bc2eaf503d6d9ca8480cae9fcf112f66e4967dc5e8fa"
+      "98285e36db8af1b8ffa8b84cb15e0fbcf836c3deb803c13f37659a60");
+}
+
+TEST(HasherFactoryTest, DefaultIsSha256) {
+  const HasherFactory& factory = DefaultHasherFactory();
+  EXPECT_THAT(factory.algorithm(), Eq(HashAlgorithm::kSha256));
+}
+
+TEST(HasherFactoryTest, DefaultIsSingleton) {
+  const HasherFactory& factory1 = DefaultHasherFactory();
+  const HasherFactory& factory2 = DefaultHasherFactory();
+  EXPECT_THAT(&factory1, Eq(&factory2));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/default/pem.cc b/crypto/default/pem.cc
new file mode 100644
index 0000000..12f8c09
--- /dev/null
+++ b/crypto/default/pem.cc
@@ -0,0 +1,62 @@
+// 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 "crypto/default/pem.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "openssl/bio.h"
+#include "openssl/mem.h"
+#include "openssl/pem.h"
+#include "openssl/x509.h"
+
+namespace credentio {
+
+absl::StatusOr<std::vector<std::string>> LoadCertsFromPem(
+    absl::string_view pem) {
+  bssl::UniquePtr<BIO> bio(BIO_new_mem_buf(pem.data(), pem.size()));
+  if (bio == nullptr) {
+    return absl::InternalError("Failed to create BIO.");
+  }
+
+  std::vector<std::string> chain;
+  while (true) {
+    bssl::UniquePtr<X509> cert(
+        PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
+    if (cert == nullptr) {
+      break;
+    }
+
+    unsigned char* der = nullptr;
+    int len = i2d_X509(cert.get(), &der);
+    if (len < 0) {
+      return absl::InternalError("Failed to convert cert to DER.");
+    }
+    chain.push_back(std::string(reinterpret_cast<char*>(der), len));
+    OPENSSL_free(der);
+  }
+
+  if (chain.empty()) {
+    return absl::InvalidArgumentError("No certificates found");
+  }
+
+  return chain;
+}
+
+}  // namespace credentio
diff --git a/crypto/default/pem.h b/crypto/default/pem.h
new file mode 100644
index 0000000..f424951
--- /dev/null
+++ b/crypto/default/pem.h
@@ -0,0 +1,33 @@
+// 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_CRYPTO_DEFAULT_PEM_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_PEM_H_
+
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Returns a vector of DER-encoded certificates loaded from a PEM string.
+absl::StatusOr<std::vector<std::string>> LoadCertsFromPem(
+    absl::string_view pem);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_PEM_H_
diff --git a/crypto/default/pem_test.cc b/crypto/default/pem_test.cc
new file mode 100644
index 0000000..ab5c580
--- /dev/null
+++ b/crypto/default/pem_test.cc
@@ -0,0 +1,68 @@
+// 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 "crypto/default/pem.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::SizeIs;
+
+constexpr absl::string_view kPemCertChain = R"(-----BEGIN CERTIFICATE-----
+MIIBtDCCATqgAwIBAgIRANPe8rJVADfD8bhzA2jtwl4wCgYIKoZIzj0EAwMwFzEV
+MBMGA1UEAxMMVGVzdCBSb290IENBMB4XDTI2MDMyOTEyMDAwMFoXDTI3MDMyOTEy
+MDAwMFowHzEdMBsGA1UEAxMUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwdjAQBgcqhkjO
+PQIBBgUrgQQAIgNiAAQ7UFokBR4PPW+a73HQPyS6myzyxvDqtr++Xo/TrELi0Y+e
+7+gJO/OmI78lzBdVdPcNxrQ4PJxCcHDI1ArOr9F6M3YYBR5x0ykRZSpFR2in2HU3
+6rvjimlPtT/l4OTeDZOjQjBAMA4GA1UdDwEB/wQEAwICBDAPBgNVHRMBAf8EBTAD
+AQH/MB0GA1UdDgQWBBT0u2grl7HnYYbNpUeacyT8BlqZ+DAKBggqhkjOPQQDAwNo
+ADBlAjEAzOZu5HO8gtLheQgoE7gdj6qpB8MBnAO8zIDZ2byp+E3Q3lOc8TGWro0O
+sY/1iAyMAjAQcNb21dwi/ZPRo70+RG5Hx70tDWzk6BtvWEr6NkEuNV/8j7Jn7/ED
+/GwOJzwrywk=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIBajCB8aADAgECAhABfuUxwcNoiwWqcTHpsCU8MAoGCCqGSM49BAMDMB8xHTAb
+BgNVBAMTFFRlc3QgSW50ZXJtZWRpYXRlIENBMB4XDTI2MDMyOTEyMDAwMFoXDTI3
+MDMyOTEyMDAwMFowHDEaMBgGA1UEAxMRVGVzdCBjbGFpbSBzaWduZXIwWTATBgcq
+hkjOPQIBBggqhkjOPQMBBwNCAAQZCL12JyasTpL9oU/mHZlDVP7j5DAAHIH+jauM
+d4ufXdNahmxHAy5dJ3n9pvnMfQeXh8ZQTjNHzqZVTvlN5o0koxIwEDAOBgNVHQ8B
+Af8EBAMCB4AwCgYIKoZIzj0EAwMDaAAwZQIxALDh3HqsATwFtu9KMOHdVq7jX0nm
+Rm13/7yh6oREzQsHX125BW5NO/95+oUY+AjbqAIwBwO4P3XxDGuIlMJBi5bc5baR
+QKnNhfW2ruLN754O4M+lDQdvVWKz5ghGCwQtfpv+
+-----END CERTIFICATE-----)";
+
+TEST(LoadCertsFromPemTest, FailsOnInvalidPem) {
+  auto chain_or = LoadCertsFromPem("not pem data");
+  EXPECT_THAT(chain_or, StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(LoadCertsFromPemTest, Succeeds) {
+  auto der_chain = LoadCertsFromPem(kPemCertChain);
+  ASSERT_TRUE(der_chain.ok());
+  EXPECT_THAT(*der_chain, SizeIs(2));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/default/timestamp_verifier.cc b/crypto/default/timestamp_verifier.cc
new file mode 100644
index 0000000..b054880
--- /dev/null
+++ b/crypto/default/timestamp_verifier.cc
@@ -0,0 +1,264 @@
+// 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 "crypto/default/timestamp_verifier.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+#include "openssl/mem.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/pki/verify.h"
+#include "openssl/pki/verify_error.h"
+#include "openssl/stack.h"
+#include "openssl/x509.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/timestamp_parsing.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+
+namespace {
+
+// Represents the `EssCertIdV2` SEQUENCE, part of the `SigningCertificateV2`
+// attribute as defined in RFC 5035.
+struct EssCertIdV2 {
+  HashAlgorithm hash_algorithm;
+  std::string cert_hash;
+};
+
+absl::Status CertMatchesEssCertId(absl::string_view cert, EssCertIdV2 id) {
+  auto hasher = CreateHasher(id.hash_algorithm);
+  if (!hasher.ok()) {
+    return absl::Status(
+        absl::StatusCode::kFailedPrecondition,
+        absl::StrCat("Invalid HashAlgorithm value: ", id.hash_algorithm, "; ",
+                     hasher.status().message()));
+  }
+  (*hasher)->Update(cert);
+  if ((*hasher)->Digest() == id.cert_hash) {
+    return absl::OkStatus();
+  } else {
+    return absl::UnauthenticatedError(
+        R"(certificate does not match EssCertIDV2 value)");
+  }
+}
+
+absl::Status CheckTsaCertificateTrust(
+    const bssl::VerifyTrustStore& tsa_roots,
+    const VerifiedTimestamp& verified_timestamp,
+    absl::Span<const EssCertIdV2> ess_cert_ids) {
+  if (ess_cert_ids.empty()) {
+    return absl::InvalidArgumentError(
+        R"(SigningCertificateV2 signed attribute is empty; at minimum, TSAs must record their own leaf certificate in this attribute)");
+  }
+  absl::Status status = CertMatchesEssCertId(
+      verified_timestamp.tsa_certificate(), ess_cert_ids[0]);
+  if (!status.ok()) {
+    return absl::Status(
+        status.code(),
+        absl::StrCat("TSA cert does not match SigningCertificateV2 attribute: ",
+                     status.message()));
+  }
+
+  auto intermediate_certs_der =
+      verified_timestamp.certificate_chain().subspan(1);
+  if (intermediate_certs_der.size() > 2) {
+    return absl::UnauthenticatedError(
+        absl::StrCat("excessive timestamp certificate chain length: ",
+                     intermediate_certs_der.size() + 1));
+  }
+
+  bssl::CertificateVerifyOptions opts;
+
+  opts.key_purpose =
+      bssl::CertificateVerifyOptions::KeyPurpose::C2PA_TIMESTAMPING;
+  opts.leaf_cert = verified_timestamp.tsa_certificate();
+  opts.intermediates.insert(opts.intermediates.end(),
+                            intermediate_certs_der.begin(),
+                            intermediate_certs_der.end());
+  // Timestamp cert chain validity periods are evaluated with respect to the
+  // attested time, per
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_time_stamp:
+  // "Validate that the attested time, as found in the genTime field (in the
+  // timeStampToken), falls within the validity period of the TSA’s signing
+  // certificate."
+  opts.time = absl::ToTimeT(verified_timestamp.asserted_time());
+  opts.trust_store = &tsa_roots;
+  bssl::VerifyError error;
+  if (!bssl::CertificateVerify(opts, &error)) {
+    if (error.Code() == bssl::VerifyError::StatusCode::CERTIFICATE_EXPIRED ||
+        error.Code() ==
+            bssl::VerifyError::StatusCode::CERTIFICATE_NOT_YET_VALID) {
+      return absl::OutOfRangeError(error.DiagnosticString());
+    }
+    return absl::UnauthenticatedError(
+        absl::StrCat("timestamp certificate chain could not be validated: ",
+                     error.DiagnosticString()));
+  }
+  return absl::OkStatus();
+}
+
+// Extracts the `certs` field of the `SigningCertificateV2` attribute (attribute
+// defined in RFC 5035).
+absl::StatusOr<std::vector<EssCertIdV2>> GetEssCertIds(
+    absl::string_view signing_certificate_v2_bytes) {
+  std::vector<EssCertIdV2> out;
+  CBS signing_certificate_cbs;
+  CBS_init(
+      &signing_certificate_cbs,
+      reinterpret_cast<const uint8_t*>(signing_certificate_v2_bytes.data()),
+      signing_certificate_v2_bytes.size());
+
+  CBS signing_certificate_sequence;
+  if (!CBS_get_asn1(&signing_certificate_cbs, &signing_certificate_sequence,
+                    CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError(
+        R"(could not parse DER SEQUENCE for SigningCertificateV2 attribute value)");
+  }
+
+  CBS certs_sequence;
+  if (!CBS_get_asn1(&signing_certificate_sequence, &certs_sequence,
+                    CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError(
+        R"(could not parse `certs` field (DER SEQUENCE) of SigningCertificateV2 attribute value)");
+  }
+
+  CBS ess_cert_id_cbs;
+  while (CBS_get_asn1(&certs_sequence, &ess_cert_id_cbs, CBS_ASN1_SEQUENCE)) {
+    // The hash algorithm is an optional field, if it is not present it defaults
+    // to SHA-256.
+    int algorithm_nid = NID_sha256;
+    CBS original = ess_cert_id_cbs;
+    CBS oid_cbs;
+    if (CBS_get_asn1(&ess_cert_id_cbs, &oid_cbs, CBS_ASN1_OBJECT)) {
+      algorithm_nid = OBJ_cbs2nid(&oid_cbs);
+      if (algorithm_nid == NID_undef) {
+        bssl::UniquePtr<char> oid_txt_uniq(CBS_asn1_oid_to_text(&oid_cbs));
+        absl::string_view oid_txt;
+        if (oid_txt_uniq != nullptr) {
+          oid_txt = oid_txt_uniq.get();
+        } else {
+          oid_txt = "<unprintable OID>";
+        }
+        return absl::InvalidArgumentError(absl::StrCat(
+            R"(unrecognized OID in `certs.hashAlgorithm` field of SigningCertificateV2 attribute value; OID value: )",
+            oid_txt));
+      }
+    } else {
+      ess_cert_id_cbs = original;
+    }
+    auto hash_algorithm = NidToHashAlgorithm(algorithm_nid);
+    if (!hash_algorithm.ok()) {
+      return absl::Status(
+          hash_algorithm.status().code(),
+          absl::StrCat(
+              "unsupported hash algorithm in `certs.hashAlgorithm` field of "
+              "SigningCertificateV2 attribute value: ",
+              hash_algorithm.status().message()));
+    }
+
+    CBS hash_cbs;
+    if (!CBS_get_asn1(&ess_cert_id_cbs, &hash_cbs, CBS_ASN1_OCTETSTRING)) {
+      return absl::InvalidArgumentError(
+          R"(could not parse `certs.hash` field (DER OCTETSTRING) of SigningCertificateV2 attribute value)");
+    }
+    out.push_back(EssCertIdV2{
+        .hash_algorithm = *hash_algorithm,
+        .cert_hash =
+            std::string(reinterpret_cast<const char*>(CBS_data(&hash_cbs)),
+                        CBS_len(&hash_cbs))});
+  }
+
+  return out;
+}
+
+}  // namespace
+
+absl::StatusOr<VerifiedTimestamp> TimestampVerifier::VerifyTimestampToken(
+    const ParsedTimestampToken& parsed_token) const {
+  ABSL_ASSIGN_OR_RETURN(auto chain, parsed_token.GetCertificateChain());
+
+  // CMS supports more digest algorithms than C2PA, so we need to check that the
+  // digest algorithm is supported.
+  ABSL_RETURN_IF_ERROR(parsed_token.GetMessageImprintHashAlgorithm().status());
+
+  if (chain.empty()) {
+    // This would indicate a bug in the CMS library.
+    return absl::InternalError("TSA leaf cert missing");
+  }
+
+  absl::Status verify_status = parsed_token.VerifySignature();
+  if (!verify_status.ok()) {
+    return absl::UnauthenticatedError(
+        absl::StrCat("the timestamp signature could not be verified: ",
+                     verify_status.message()));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto signing_certificate_v2_bytes,
+                        parsed_token.GetSigningCertificateV2Bytes());
+
+  ABSL_ASSIGN_OR_RETURN(auto ess_cert_ids,
+                        GetEssCertIds(signing_certificate_v2_bytes));
+
+  ABSL_ASSIGN_OR_RETURN(auto tst_info_bytes, parsed_token.GetTstInfoBytes());
+  CBS cbs;
+  CBS_init(&cbs, reinterpret_cast<const uint8_t*>(tst_info_bytes.data()),
+           tst_info_bytes.size());
+
+  absl::Time time;
+  std::string message_imprint_hash;
+  HashAlgorithm hash_algorithm;
+  std::string nonce;
+  ABSL_RETURN_IF_ERROR(
+      ParseTstInfo(cbs, &time, &message_imprint_hash, &hash_algorithm, &nonce));
+  ABSL_ASSIGN_OR_RETURN(
+      auto verified_timestamp_or,
+      VerifiedTimestamp::Create(time, std::move(chain),
+                                std::move(message_imprint_hash), hash_algorithm,
+                                nonce));
+  VerifiedTimestamp verified_timestamp = verified_timestamp_or;
+
+  if (tsa_roots_ == nullptr) {
+    ABSL_LOG_EVERY_N_SEC(INFO, 60)
+        << "Bypassing C2PA TSA certificate trust check.";
+  } else {
+    absl::Status trust_status =
+        CheckTsaCertificateTrust(*tsa_roots_, verified_timestamp, ess_cert_ids);
+    ABSL_RETURN_IF_ERROR(trust_status);
+  }
+  ABSL_VLOG(1) << "Verified timestamp: " << verified_timestamp.asserted_time();
+  return verified_timestamp;
+}
+
+}  // namespace credentio
diff --git a/crypto/default/timestamp_verifier.h b/crypto/default/timestamp_verifier.h
new file mode 100644
index 0000000..5b31fbf
--- /dev/null
+++ b/crypto/default/timestamp_verifier.h
@@ -0,0 +1,52 @@
+// 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.
+//
+
+// Support for RFC 3161 TimestampToken verification.
+#ifndef THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TIMESTAMP_VERIFIER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TIMESTAMP_VERIFIER_H_
+
+#include "absl/base/attributes.h"
+#include "absl/base/nullability.h"
+#include "absl/status/statusor.h"
+#include "openssl/pki/verify.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+
+// Verifier for RFC 3161 TimestampTokens.
+class TimestampVerifier {
+ public:
+  // If `tsa_roots` is `nullptr`, the verifier will skip trust checks (only for
+  // use in tests).
+  explicit TimestampVerifier(const bssl::VerifyTrustStore* absl_nullable
+                                 tsa_roots ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : tsa_roots_(tsa_roots) {}
+
+  // Verifies the signature of an RFC 3161 TimestampToken, and
+  // checks that the TSA is trusted. Error codes reported include
+  //   kInvalidArgument: Malformed timestamp token
+  //   kUnauthenticated: Signature verification errors, missing/untrusted certs
+  //   kOutOfRange: A certificate was not within its validity period
+  absl::StatusOr<VerifiedTimestamp> VerifyTimestampToken(
+      const ParsedTimestampToken& parsed_token) const;
+
+ private:
+  const bssl::VerifyTrustStore* tsa_roots_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TIMESTAMP_VERIFIER_H_
diff --git a/crypto/default/timestamp_verifier_test.cc b/crypto/default/timestamp_verifier_test.cc
new file mode 100644
index 0000000..7cba807
--- /dev/null
+++ b/crypto/default/timestamp_verifier_test.cc
@@ -0,0 +1,754 @@
+// 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 "crypto/default/timestamp_verifier.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <iterator>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/algorithm/container.h"
+#include "absl/base/no_destructor.h"
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "crypto/cbs_utils.h"
+#include "crypto/default/trust_store.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/base.h"
+#include "openssl/bio.h"
+#include "openssl/bytestring.h"
+#include "openssl/digest.h"
+#include "openssl/mem.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/pem.h"
+#include "openssl/pki/verify.h"
+#include "openssl/x509.h"
+#include "proto/validation_result.pb.h"
+#include "tsp/constants.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/test_helpers.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::ElementsAre;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+using ::testing::Return;
+
+constexpr absl::string_view kCaPem = R"(-----BEGIN CERTIFICATE-----
+MIIB3zCCAWagAwIBAgIUXJz+6VKGMVOVs+xupRS3Cne3or0wCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw
+NDE1MTMyMFoYDzIxMjUwMTExMTUxMzIwWjAmMSQwIgYDVQQDDBtHb29nbGUgQzJQ
+QSBUZXN0aW5nIFJvb3QgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATWa/wOwQMD
+5zNmXVNGbPJuptbvXrNBhgKZI6SHRzngJ4bV+SShgLDqFdtnDfwAO/nxHyACTNl6
+sJ9ue407+HW6W1WEODD9a/jVYizAVkzUaEDElDEA5PPkegHqtVJuf8CjUzBRMB0G
+A1UdDgQWBBTTMOrQr2ny9gYyywPGUZGx/KENtzAfBgNVHSMEGDAWgBTTMOrQr2ny
+9gYyywPGUZGx/KENtzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQC
+MCsAxaLyEkp2nyQ2BtBoSuu52k3qfWLNAOHiaifoaxhQzDo1juNYuITM8I88PwVO
+QAIwJBRVRNysxvouMf7cQ2VSYTPc32BY/m38wnWS3K94l7eS3sANM9U++e8dgEtG
+U2Tv
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kTsaPem = R"(-----BEGIN CERTIFICATE-----
+MIIB1zCCAVygAwIBAgIUaUM3tWVucTzvVWUNY0HjFKBAEkwwCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw
+NDIxNDIxNFoYDzIxMjUwMTExMjE0MjE0WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ
+QSBUZXN0aW5nIFRTQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLlLssu8MJdD
+CS3UV4CPcI1l4lnQ7Y6V7jAaAXTt7wbRnN8lbjcMmfzY4mqhc6HMtFYqYj4UeO7t
+tjkvzI2PfGmjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEF
+BQcDCDAdBgNVHQ4EFgQUeWySrF5tKc2Cwe7AzkxALXVXyIwwHwYDVR0jBBgwFoAU
+0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaQAwZgIxAPwCKKGMymYV
+8p2gXDDyJSMbHJnfHu/FvfeaWYF6a6twCCt7IFehJWbyg/f1p/3Q7QIxAK0yLI6L
+/FTQPRaB3nZBm4Ocw9QZ171qisDKd1wW83mjmFAp9aNFlFv2yQt9A1NeEQ==
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kTsaRsaPem = R"(-----BEGIN CERTIFICATE-----
+MIIDoDCCAyegAwIBAgIUDDONK2J0GuK8OSkZYI4sN4GGJaYwCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw
+NTAwNDU1N1oYDzIxMjUwMTEyMDA0NTU3WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ
+QSBUZXN0aW5nIFRTQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO5N
+FYAVihiznVBVhnpoOIvz790xxqdpZXRYrCXkj9L2o07sjUza8cotpvDzd9W8EkPg
+CJSl0dJ4VeZ7wq4PH0azLgpWDppTVtUtzbYGWacvgWsgLQ96SqAmPYnuHwD+b+Wv
+q/p35JB3csFDpg2F9SKFHZH50KQJ4z+WLAKf9Gexfs0y8DsN3DtQgzaVF2vS7/SL
+jfFbnS1aHXoeKvPiu475pltkuw0velbXi8/cHIq3VuQOwxgyCIE4FuWJYYPMMu5j
+HJ0+bBtDI+Nn8rWmN7hpOV1Lo268gzwR2HtGmdM1LABj+RY8nBdyXJmfAY9kqm/D
+VuEVrsGdIOYiAw7iHLvjc4CfNaKs6l9bhTppsVydLZE1fKuMFvmSs5Gc9yIermeu
+jmoPB6QSVJh76mKOug7BmEzLHipFRxfDOmdr+eP/5w3L1ZOA+nEG2XWVICbIH/ND
+rteeFCGJsDV7wDx6Yhpfvjh8A5qP9Z41MW8CtqpzNakivtVaDKmULZ/dyKeI8BZ5
+DvEg/6mpwN6Eu06czep0tO4LQ/l3935w3d/lkpn1yKDu9nm02uvVBEqwZW2lR5BT
+MeqxQTMrrbmoi0mjsyX+5XmsUzAzBFlZ9HJi14p6vd70FJOlRXttGxCxiLC/QkmN
+krFnFH7v1bhbRGGtKqttUK4yogdnxaOhZ6gr/s45AgMBAAGjajBoMA4GA1UdDwEB
+/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAdBgNVHQ4EFgQU+rL+AIZ5
+EsQVhIN6SfX34LSTS7YwHwYDVR0jBBgwFoAU0zDq0K9p8vYGMssDxlGRsfyhDbcw
+CgYIKoZIzj0EAwIDZwAwZAIwDrCJbyS2Z4yA+82o7slKap3VpBwqBW5xrYjIJyV3
+IGzPI3i55bQb0wKRf6SMBHmCAjAfrVYauA+rpNxEYEIoM/ziaJ0xEbF/tw/JQyVE
+0JW905o/hzXHO5LV3z/x0SYRPiY=
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kTsaWithIntermediatePem =
+    R"(-----BEGIN CERTIFICATE-----
+MIIBvzCCAWSgAwIBAgIUImopbS3q7uF8Wz88f39/wfxAIYowCgYIKoZIzj0EAwIw
+LjEsMCoGA1UEAwwjR29vZ2xlIEMyUEEgVGVzdGluZyBJbnRlcm1lZGlhdGUgQ0Ew
+IBcNMjUwMzEzMTM1NzI2WhgPMjEyNTAyMTcxMzU3MjZaMCIxIDAeBgNVBAMMF0dv
+b2dsZSBDMlBBIFRlc3RpbmcgVFNBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE
+Z9YEmeJodxdGQdhJbSjIeccIbxjIQY5rPp/c6zqF4/mRr8VOdMur3r/r0Gyxju8k
+7R+6/j5IjQwzci01Qs7W06NqMGgwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM
+MAoGCCsGAQUFBwMIMB0GA1UdDgQWBBQGI2mf9lUe+oNSZE/WRQlwVGMgoTAfBgNV
+HSMEGDAWgBRmRE2zkZe58xIqg//9Iw76XyOLKjAKBggqhkjOPQQDAgNJADBGAiEA
+onFfvfflN96uF0ECnej2aZ7ikuyz+OsDEQVDQZNBTHMCIQCo5YiNm7EeeMexhDzM
+vVdDSKzUFmzJXGZAO/kkEQyNXw==
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kIntermediatePem = R"(-----BEGIN CERTIFICATE-----
+MIIByjCCAVGgAwIBAgIUVhW73Ry+Zn7eKwhnxTiyLDJ3cKowCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDMx
+MzEzMzc0MVoYDzIxMjUwMjE3MTMzNzQxWjAuMSwwKgYDVQQDDCNHb29nbGUgQzJQ
+QSBUZXN0aW5nIEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH
+A0IABJKYZuxD1F5Eqkgl+fqnubgtLr4MHlAh2p/7n8E/BOZuemrAAXVw7p27D1Oy
+n6RNyobJVRZHcm61i09hfTKw/NajUzBRMB0GA1UdDgQWBBRmRE2zkZe58xIqg//9
+Iw76XyOLKjAfBgNVHSMEGDAWgBTTMOrQr2ny9gYyywPGUZGx/KENtzAPBgNVHRMB
+Af8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQCMBqHAcfcOv21PmRMWchgZ37ZRoor
+4+XlKPnh+L0gBBRykqI49VvvpgxtCC2jVA2BnQIwZf0DOnk6c4lUvSzrEJ7+ToR1
+dJMoNuufOTVoMQdojCLwnH4GrwID0qsdXJm8X0XY
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kClaimSignerPem = R"(-----BEGIN CERTIFICATE-----
+MIICDzCCAZWgAwIBAgIUR8DAqLjMOExo0KJS3DGC4Qsy79kwCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIy
+MzA1MDMwMFoYDzIxMjUwMTMwMDUwMzAwWjBbMSYwJAYDVQQDDB1Hb29nbGUgVGVz
+dCBDMlBBIENsYWltIFNpZ25lcjEdMBsGA1UECgwUR29vZ2xlIEMyUEEgVGVzdCBP
+cmcxEjAQBgNVBAsMCVRlc3QgVW5pdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA
+BKw1/4qPpbSgEWNUy7B5R48dxnsx92d5lqh7HY204TpvmxQgukcwavpW8XBxSdYr
+F+XEgNStkprWpnkoKWNpShyjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
+DDAKBggrBgEFBQcDBDAdBgNVHQ4EFgQU6wle6tElLcBSIwsoBICb7gInPiQwHwYD
+VR0jBBgwFoAU0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaAAwZQIw
+YNgGpaaZxI2km45t/Wd+njvQlF0JJ4+9GvZ0bDQWH7kjAQTmKrkxuNMT77cA2IMW
+AjEArZqFNmE/5yv8tzHbPP73c3n8BPUmjIpZR5A2nzjfhPzpClFp70lkO+mTu82d
+ULnv
+-----END CERTIFICATE-----)";
+
+class MockParsedTimestampToken : public ParsedTimestampToken {
+ public:
+  MOCK_METHOD(absl::Status, VerifySignature, (), (const, override));
+  MOCK_METHOD(absl::StatusOr<HashAlgorithm>, GetMessageImprintHashAlgorithm, (),
+              (const, override));
+  MOCK_METHOD(absl::StatusOr<std::vector<std::string>>, GetCertificateChain, (),
+              (const, override));
+  MOCK_METHOD(absl::StatusOr<absl::string_view>, GetSigningCertificateV2Bytes,
+              (), (const, override));
+  MOCK_METHOD(absl::StatusOr<absl::string_view>, GetTstInfoBytes, (),
+              (const, override));
+};
+
+std::string GetSigningCertificateV2Value(absl::string_view attribute_bytes) {
+  CBS cbs = FromStringView(attribute_bytes);
+  CBS seq, oid, set;
+  CHECK(CBS_get_asn1(&cbs, &seq, CBS_ASN1_SEQUENCE));
+  CHECK(CBS_get_asn1(&seq, &oid, CBS_ASN1_OBJECT));
+  CHECK(CBS_get_asn1(&seq, &set, CBS_ASN1_SET));
+  CBS val_elem;
+  CHECK(CBS_get_any_asn1_element(&set, &val_elem, nullptr, nullptr));
+  return std::string(ToStringView(val_elem));
+}
+
+constexpr absl::string_view kSha384OidTxt = "2.16.840.1.101.3.4.2.2";
+
+constexpr absl::string_view kMessageImprintHash = "fake message imprint";
+constexpr absl::string_view kValidTimestamp = "20240101000000Z";
+
+absl::Time ValidTimestampTime() {
+  static absl::Time time = []() {
+    absl::Time time;
+    std::string err;
+    CHECK(absl::ParseTime(kGenTimeFormat, kValidTimestamp, &time, &err)) << err;
+    return time;
+  }();
+
+  return time;
+}
+
+bssl::UniquePtr<X509> ParseCert(absl::string_view pem) {
+  CBS cbs = FromStringView(pem);
+  bssl::UniquePtr<BIO> bio(BIO_new_mem_buf(CBS_data(&cbs), CBS_len(&cbs)));
+  CHECK(bio != nullptr);
+  bssl::UniquePtr<X509> x509(
+      PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
+  CHECK(x509 != nullptr);
+  return x509;
+}
+
+std::string RawDer(X509* x509) {
+  uint8_t* der_ptr = nullptr;
+  size_t der_len = i2d_X509(x509, &der_ptr);
+  bssl::UniquePtr<uint8_t> der(der_ptr);
+
+  return std::string(reinterpret_cast<char*>(der_ptr), der_len);
+}
+
+std::string RawDer(absl::string_view pem) {
+  return RawDer(ParseCert(pem).get());
+}
+
+absl::Time ParseGenTime(absl::string_view timestamp) {
+  absl::Time time;
+  std::string parse_error;
+  CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error))
+      << "ParseTime(\"" << kGenTimeFormat << "\", \"" << timestamp
+      << "\"): " << parse_error;
+  return time;
+}
+
+absl::string_view ValidNonce() {
+  static absl::NoDestructor<std::string> nonce(EncodeDerInteger(0xf00dbabe));
+  return *nonce;
+}
+
+std::string SigningCertificateV2AttributeFromPem(
+    absl::Span<const absl::string_view> pems) {
+  std::vector<bssl::UniquePtr<X509>> certs;
+  for (absl::string_view pem : pems) {
+    certs.push_back(ParseCert(pem));
+  }
+  std::vector<X509*> ptr_vec;
+  absl::c_transform(certs, std::back_inserter(ptr_vec),
+                    [](const auto& cert) { return cert.get(); });
+  return CreateSigningCertificateV2Attribute(absl::MakeSpan(ptr_vec),
+                                             HashAlgorithm::kSha256);
+}
+
+class TimestampVerifierTest : public testing::Test {
+ protected:
+  TimestampVerifierTest() : no_trust_checks_(/*tsa_roots=*/nullptr) {}
+
+  void SetupMockDefaults(MockParsedTimestampToken& mock_token,
+                         const std::vector<std::string>& cert_chain,
+                         absl::string_view v2_val, absl::string_view tst_info) {
+    ON_CALL(mock_token, GetCertificateChain())
+        .WillByDefault(Return(cert_chain));
+    ON_CALL(mock_token, GetMessageImprintHashAlgorithm())
+        .WillByDefault(Return(HashAlgorithm::kSha384));
+    ON_CALL(mock_token, VerifySignature())
+        .WillByDefault(Return(absl::OkStatus()));
+    ON_CALL(mock_token, GetSigningCertificateV2Bytes())
+        .WillByDefault(Return(v2_val));
+    ON_CALL(mock_token, GetTstInfoBytes()).WillByDefault(Return(tst_info));
+  }
+
+  TimestampVerifier no_trust_checks_;
+};
+
+TEST_F(TimestampVerifierTest, AcceptsRsaTimestamp) {
+  std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash,
+                                       kSha384OidTxt, ValidNonce());
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaRsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token);
+  ASSERT_TRUE(timestamp.ok());
+  EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime());
+  EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash));
+  EXPECT_THAT(timestamp->message_imprint_hash_algorithm(),
+              Eq(HashAlgorithm::kSha384));
+  EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce()));
+  EXPECT_THAT(timestamp->tsa_certificate(), Eq(RawDer(kTsaRsaPem)));
+  EXPECT_THAT(timestamp->certificate_chain(), ElementsAre(RawDer(kTsaRsaPem)));
+}
+
+TEST_F(TimestampVerifierTest, AcceptsEcdsaTimestamp) {
+  std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash,
+                                       kSha384OidTxt, ValidNonce());
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token);
+  ASSERT_TRUE(timestamp.ok());
+  EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime());
+  EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash));
+  EXPECT_THAT(timestamp->message_imprint_hash_algorithm(),
+              Eq(HashAlgorithm::kSha384));
+  EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce()));
+  EXPECT_THAT(timestamp->tsa_certificate(), Eq(cert_der));
+  EXPECT_THAT(timestamp->certificate_chain(), ElementsAre(cert_der));
+}
+
+TEST_F(TimestampVerifierTest, AcceptsTimestampWithIntermediateCertificate) {
+  std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash,
+                                       kSha384OidTxt, ValidNonce());
+  std::string v2_attr =
+      SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaWithIntermediatePem);
+  std::string intermediate_der = RawDer(kIntermediatePem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der, intermediate_der}, v2_val, tst_info);
+
+  auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token);
+  ASSERT_TRUE(timestamp.ok());
+  EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime());
+  EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash));
+  EXPECT_THAT(timestamp->message_imprint_hash_algorithm(),
+              Eq(HashAlgorithm::kSha384));
+  EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce()));
+  EXPECT_THAT(timestamp->tsa_certificate(), Eq(cert_der));
+  EXPECT_THAT(timestamp->certificate_chain(),
+              ElementsAre(cert_der, intermediate_der));
+}
+
+TEST_F(TimestampVerifierTest, GetSigningCertificateV2BytesFails) {
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return(absl::InvalidArgumentError("Missing attribute")));
+
+  EXPECT_THAT(
+      no_trust_checks_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kInvalidArgument, "Missing attribute"));
+}
+
+TEST_F(TimestampVerifierTest, InvalidTstInfoFails) {
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {RawDer(kTsaRsaPem)}, v2_val,
+                    "THIS IS NOT BER OR DER OR CER OR AER OR ...");
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("could not read top-level sequence")));
+}
+
+TEST_F(TimestampVerifierTest, SignatureVerificationFails) {
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature())
+      .WillOnce(Return(absl::UnauthenticatedError("Signature failed")));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kUnauthenticated,
+                       HasSubstr("Signature failed")));
+}
+
+TEST_F(TimestampVerifierTest, EmptyChainFails) {
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInternal,
+                       HasSubstr("TSA leaf cert missing")));
+}
+
+TEST_F(TimestampVerifierTest, InvalidSigningCertificateAttribute) {
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return("THIS IS NOT A DER SEQUENCE"));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "could not parse DER SEQUENCE for SigningCertificateV2 "
+                       "attribute value"));
+}
+
+TEST_F(TimestampVerifierTest, UnrecognizedSiginingCertificateHashAlgorithm) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+
+  constexpr absl::string_view kFakeOid = "1.2.840.113549.1.800.888.8888";
+  CBS hash_cbs = FromStringView(
+      "thisisnotarealhashbutthetestwontcarebecauseitwillfailduetothebadoidanywa"
+      "ys");
+
+  CBB certs_entry_cbb;
+  CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+  CBB obj_cbb;
+  CHECK(CBB_add_asn1(&certs_entry_cbb, &obj_cbb, CBS_ASN1_OBJECT));
+  CHECK(
+      CBB_add_asn1_oid_from_text(&obj_cbb, kFakeOid.data(), kFakeOid.length()));
+  CHECK(CBB_flush(&certs_entry_cbb));
+
+  CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs),
+                                  CBS_len(&hash_cbs)));
+
+  auto value_bytes = FinishToString(&cbb);
+  ASSERT_TRUE(value_bytes.ok());
+
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return(*value_bytes));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "unrecognized OID in `certs.hashAlgorithm` field of "
+                       "SigningCertificateV2 attribute value; OID value: "
+                       "1.2.840.113549.1.800.888.8888"));
+}
+
+TEST_F(TimestampVerifierTest, NoSigningCertificateHash) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+
+  CBB certs_entry_cbb;
+  CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+  CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha384));
+  CHECK(CBB_flush(&certs_entry_cbb));
+
+  auto value_bytes = FinishToString(&cbb);
+  ASSERT_TRUE(value_bytes.ok());
+
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return(*value_bytes));
+
+  EXPECT_THAT(
+      no_trust_checks_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               "could not parse `certs.hash` field (DER OCTETSTRING) of "
+               "SigningCertificateV2 attribute value"));
+}
+
+TEST_F(TimestampVerifierTest, UnsupportedSigningCertificateHashAlgorithm) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+
+  CBS hash_cbs = FromStringView("Not a real hash");
+
+  CBB certs_entry_cbb;
+  CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+  CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha1));
+  CHECK(CBB_flush(&certs_entry_cbb));
+
+  CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs),
+                                  CBS_len(&hash_cbs)));
+
+  auto value_bytes = FinishToString(&cbb);
+  ASSERT_TRUE(value_bytes.ok());
+
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return(*value_bytes));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "unsupported hash algorithm in `certs.hashAlgorithm` "
+                       "field of SigningCertificateV2 attribute value: hash "
+                       "algorithm NID is not on C2PA allowlist: 64"));
+}
+
+class TrustChecksTest : public ::testing::Test {
+ protected:
+  TrustChecksTest()
+      : tsa_roots_(LoadTestRoots().value()), verifier_(tsa_roots_.get()) {}
+
+  absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> LoadTestRoots() {
+    return LoadTrustStore(kCaPem);
+  }
+
+  void SetupMockDefaults(MockParsedTimestampToken& mock_token,
+                         const std::vector<std::string>& cert_chain,
+                         absl::string_view v2_val, absl::string_view tst_info) {
+    ON_CALL(mock_token, GetCertificateChain())
+        .WillByDefault(Return(cert_chain));
+    ON_CALL(mock_token, GetMessageImprintHashAlgorithm())
+        .WillByDefault(Return(HashAlgorithm::kSha384));
+    ON_CALL(mock_token, VerifySignature())
+        .WillByDefault(Return(absl::OkStatus()));
+    ON_CALL(mock_token, GetSigningCertificateV2Bytes())
+        .WillByDefault(Return(v2_val));
+    ON_CALL(mock_token, GetTstInfoBytes()).WillByDefault(Return(tst_info));
+  }
+
+  const std::unique_ptr<bssl::VerifyTrustStore> tsa_roots_;
+  TimestampVerifier verifier_;
+};
+
+TEST_F(TrustChecksTest, Trusted) {
+  constexpr absl::string_view kTime = "20250205000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  auto verified_timestamp = verifier_.VerifyTimestampToken(mock_token);
+  ASSERT_TRUE(verified_timestamp.ok());
+  EXPECT_THAT(verified_timestamp->asserted_time(), Eq(ParseGenTime(kTime)));
+}
+
+TEST_F(TrustChecksTest, TrustedWithIntermediate) {
+  constexpr absl::string_view kTime = "20250514000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr =
+      SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaWithIntermediatePem);
+  std::string intermediate_der = RawDer(kIntermediatePem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der, intermediate_der}, v2_val, tst_info);
+
+  auto verified_timestamp = verifier_.VerifyTimestampToken(mock_token);
+  ASSERT_TRUE(verified_timestamp.ok());
+  EXPECT_THAT(verified_timestamp->asserted_time(), Eq(ParseGenTime(kTime)));
+}
+
+TEST_F(TrustChecksTest, TooManyIntermediateCerts) {
+  constexpr absl::string_view kTime = "20250514000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr =
+      SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaWithIntermediatePem);
+  std::string intermediate_der = RawDer(kIntermediatePem);
+
+  MockParsedTimestampToken mock_token;
+  // Chain length 4 (1 leaf + 3 intermediates)
+  SetupMockDefaults(
+      mock_token,
+      {cert_der, intermediate_der, intermediate_der, intermediate_der}, v2_val,
+      tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kUnauthenticated,
+               HasSubstr("excessive timestamp certificate chain length")));
+}
+
+TEST_F(TrustChecksTest, TrustedMissingIntermediate) {
+  constexpr absl::string_view kTime = "20250514000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr =
+      SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaWithIntermediatePem);
+
+  MockParsedTimestampToken mock_token;
+  // Missing intermediate!
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kUnauthenticated,
+                       HasSubstr("No matching issuer found")));
+}
+
+TEST_F(TrustChecksTest, Untrusted) {
+  constexpr absl::string_view kTime = "20250205000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+
+  std::string dummy_cert = "DUMMY_CERT_DER";
+  // Compute SHA256 of dummy_cert using internal OpenSSL without hasher.h
+  uint8_t hash[32];
+  unsigned int hash_len;
+  CHECK(EVP_Digest(dummy_cert.data(), dummy_cert.length(), hash, &hash_len,
+                   EVP_sha256(), nullptr));
+  std::string digest(reinterpret_cast<char*>(hash), hash_len);
+
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_entry_cbb;
+  CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+  CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha256));
+  CHECK(CBB_flush(&certs_entry_cbb));
+  CBS hash_cbs = FromStringView(digest);
+  CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs),
+                                  CBS_len(&hash_cbs)));
+  auto v2_val = FinishToString(&cbb);
+  ASSERT_TRUE(v2_val.ok());
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {dummy_cert}, *v2_val, tst_info);
+
+  EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kUnauthenticated,
+                       HasSubstr("timestamp certificate chain")));
+}
+
+TEST_F(TrustChecksTest, InvalidEku) {
+  constexpr absl::string_view kTime = "20250205000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kClaimSignerPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kClaimSignerPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  // The certificate has Email Protection EKU, which is not allowed for TSAs.
+  EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kUnauthenticated,
+                       HasSubstr("prohibited key usage")));
+}
+
+TEST_F(TrustChecksTest, SigningCertificateV2AttributeLeafMismatch) {
+  constexpr absl::string_view kTime = "21250204161300Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(
+          absl::StatusCode::kUnauthenticated,
+          HasSubstr("TSA cert does not match SigningCertificateV2 attribute: "
+                    "certificate does not match EssCertIDV2 value")));
+}
+
+TEST_F(TrustChecksTest, EmptySigningCertificateAttribute) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+
+  auto value_bytes = FinishToString(&cbb);
+  ASSERT_TRUE(value_bytes.ok());
+
+  constexpr absl::string_view kTime = "20250205000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string cert_der = RawDer(kTsaRsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, *value_bytes, tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          "SigningCertificateV2 signed attribute is empty; at minimum, "
+          "TSAs must record their own leaf certificate in this attribute"));
+}
+
+TEST_F(TrustChecksTest, OutsideTsaCertValidityWindow) {
+  // This time is outside the validity window of the test TSA certificate,
+  // but inside the validity window of the test CA certificate.
+  constexpr absl::string_view kTime = "20250204161300Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kOutOfRange, HasSubstr("before notBefore")));
+}
+
+TEST_F(TrustChecksTest, OutsideCaCertValidityWindow) {
+  // This time is inside the validity window of the test TSA certificate,
+  // but outside the validity window of the test CA certificate.
+  constexpr absl::string_view kTime = "21250204161300Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kOutOfRange, HasSubstr("after notAfter")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/default/trust_store.cc b/crypto/default/trust_store.cc
new file mode 100644
index 0000000..9c6fb91
--- /dev/null
+++ b/crypto/default/trust_store.cc
@@ -0,0 +1,58 @@
+// 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 "crypto/default/trust_store.h"
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/default/pem.h"
+#include "google/protobuf/timestamp.pb.h"
+#include "openssl/pki/verify.h"
+#include "proto/signature_info.pb.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> LoadTrustStore(
+    absl::string_view trust_anchor_pem) {
+  // Construct a trust store from certs in the PEM file.
+  //
+  // 1) Parse the PEM file contents to a set of DER certs.
+  ABSL_ASSIGN_OR_RETURN(std::vector<std::string> certs,
+                        LoadCertsFromPem(trust_anchor_pem));
+  if (certs.empty()) {
+    return absl::InvalidArgumentError("No certs found in PEM file");
+  }
+
+  // 2) Construct the trust store from the DER certs.
+  std::vector<std::string_view> der_certs_views(certs.begin(), certs.end());
+  std::string diagnostics;
+  std::unique_ptr<bssl::VerifyTrustStore> roots =
+      bssl::VerifyTrustStore::FromDER(der_certs_views, &diagnostics);
+  if (roots == nullptr) {
+    return absl::InternalError(
+        absl::StrCat("Failed to construct the trust store: ", diagnostics));
+  }
+  return roots;
+}
+
+}  // namespace credentio
diff --git a/crypto/default/trust_store.h b/crypto/default/trust_store.h
new file mode 100644
index 0000000..f56be28
--- /dev/null
+++ b/crypto/default/trust_store.h
@@ -0,0 +1,34 @@
+// 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_CRYPTO_DEFAULT_TRUST_STORE_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TRUST_STORE_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "openssl/pki/verify.h"
+
+namespace credentio {
+
+// Loads a trust store from a PEM file containing a set of certificates.
+// Returns an error if the PEM file is malformed or no certs are found.
+absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> LoadTrustStore(
+    absl::string_view trust_anchor_pem);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_TRUST_STORE_H_
diff --git a/crypto/default/trust_store_test.cc b/crypto/default/trust_store_test.cc
new file mode 100644
index 0000000..d5bafb5
--- /dev/null
+++ b/crypto/default/trust_store_test.cc
@@ -0,0 +1,60 @@
+// 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 "crypto/default/trust_store.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "testing/test_file_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+constexpr absl::string_view kTrustAnchors =
+    "c2pa/testing/testdata/keys/trust_anchors.pem";
+
+TEST(LoadTrustStore, Success) {
+  auto pem = credentio_testing::GetContents(kTrustAnchors);
+  ASSERT_TRUE(pem.ok());
+  EXPECT_THAT(credentio::LoadTrustStore(*pem), IsOk());
+}
+
+TEST(LoadTrustStore, EmptyPem) {
+  EXPECT_THAT(credentio::LoadTrustStore(""),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No certificates found")));
+}
+
+TEST(LoadTrustStore, MalformedPem) {
+  EXPECT_THAT(credentio::LoadTrustStore(R"(
+-----BEGIN CERTIFICATE-----
+blah blah
+-----END CERTIFICATE-----
+)"),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No certificates found")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/default/x509_certificate.cc b/crypto/default/x509_certificate.cc
new file mode 100644
index 0000000..7f42d85
--- /dev/null
+++ b/crypto/default/x509_certificate.cc
@@ -0,0 +1,457 @@
+// 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 "crypto/default/x509_certificate.h"
+
+#include <cstdint>
+#include <ctime>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/no_destructor.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/ascii.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "absl/time/time.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/compliance_checker.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/bio.h"
+#include "openssl/bn.h"
+#include "openssl/evp.h"
+#include "openssl/obj.h"
+#include "openssl/pem.h"
+#include "openssl/pool.h"
+#include "openssl/rsa.h"
+#include "openssl/x509.h"
+#include "proto/common.pb.h"
+#include "tink/cleartext_keyset_handle.h"
+#include "tink/public_key_verify.h"
+#include "tink/signature/config_v0.h"
+#include "tink/signature/signature_pem_keyset_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::crypto::tink::PemKeyParams;
+using ::crypto::tink::SignaturePemKeysetReaderBuilder;
+
+// c2pa-al from
+// https://github.com/c2pa-org/conformance-public/blob/main/docs/current/schemas/mib/oid.txt
+constexpr char kAssuranceLevelOid[] = "1.3.6.1.4.1.62558.3";
+// c2pa-cpl-record from
+// https://github.com/c2pa-org/conformance-public/blob/main/docs/current/schemas/mib/oid.txt
+constexpr char kConformanceRecordOid[] = "1.3.6.1.4.1.62558.4";
+
+absl::Status CheckCompatibleWithAlgorithm(EVP_PKEY* pub_key,
+                                          SigningAlgorithm algorithm) {
+  int key_type = EVP_PKEY_id(pub_key);
+  switch (algorithm) {
+    case SigningAlgorithm::kPs256:
+    case SigningAlgorithm::kPs384:
+    case SigningAlgorithm::kPs512:
+      if (key_type == EVP_PKEY_RSA || key_type == EVP_PKEY_RSA_PSS) {
+        return absl::OkStatus();
+      }
+      break;
+    case SigningAlgorithm::kEs256:
+    case SigningAlgorithm::kEs384:
+    case SigningAlgorithm::kEs512:
+      if (key_type == EVP_PKEY_EC) {
+        return absl::OkStatus();
+      }
+      break;
+    case SigningAlgorithm::kEdDsa:
+      if (key_type == EVP_PKEY_ED25519) {
+        return absl::OkStatus();
+      }
+      break;
+  }
+  return absl::InvalidArgumentError(absl::Substitute(
+      "Wrong key type ($0) for algorithm $1", key_type, algorithm));
+}
+
+// Returns a list of C2PA supported algorithms.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#certificate-requirements.
+// Note: bssl::GetDefaultEVPAlgorithms() does not include RSA_PSS.
+const std::vector<const EVP_PKEY_ALG*>& GetC2paEVPAlgorithms() {
+  static const absl::NoDestructor<std::vector<const EVP_PKEY_ALG*>> kAlgorithms(
+      {
+          EVP_pkey_ec_p256(),
+          EVP_pkey_ec_p384(),
+          EVP_pkey_ec_p521(),
+          EVP_pkey_rsa(),
+          EVP_pkey_rsa_pss_sha256(),
+          EVP_pkey_rsa_pss_sha384(),
+          EVP_pkey_rsa_pss_sha512(),
+          EVP_pkey_ed25519(),
+      });
+  return *kAlgorithms;
+}
+
+absl::StatusOr<bssl::UniquePtr<X509>> GetCertificateFromDER(
+    absl::string_view der_data) {
+  auto crypto_buffer = bssl::UniquePtr<CRYPTO_BUFFER>(
+      CRYPTO_BUFFER_new(reinterpret_cast<const uint8_t*>(der_data.data()),
+                        der_data.length(), /*pool=*/nullptr));
+  const auto& algorithms = GetC2paEVPAlgorithms();
+  bssl::UniquePtr<X509> cert(X509_parse_with_algorithms(
+      crypto_buffer.get(), algorithms.data(), algorithms.size()));
+  if (cert == nullptr) {
+    return absl::InvalidArgumentError(
+        "Failed to parse the X.509 certificate from DER bytes");
+  }
+  return cert;
+}
+
+absl::StatusOr<std::string> PemEncode(EVP_PKEY* pub_key) {
+  bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem()));
+  if (PEM_write_bio_PUBKEY(bio.get(), pub_key) != 1) {
+    return absl::InternalError("Failed to write pub key to BIO");
+  }
+  const uint8_t* pem;
+  size_t pem_length = 0;
+  if (BIO_mem_contents(bio.get(), &pem, &pem_length) != 1) {
+    return absl::InternalError("Failed to get PEM string from BIO");
+  }
+  std::string pem_str(reinterpret_cast<const char*>(pem), pem_length);
+  return pem_str;
+}
+
+bssl::UniquePtr<EVP_PKEY> MaybeConvertPssToRsa(
+    bssl::UniquePtr<EVP_PKEY> pub_key) {
+  // Tink uses BoringSSL to parse public keys from PEM, which does not support
+  // id-RSASSA-PSS by default. Since Tink uses key templates, it doesn't care if
+  // the original public key had the "restricted" RSA-PSS OID or the "standard"
+  // RSA OID. It only cares about the modulus and exponent, then applies the PSS
+  // logic defined in the GetPemKeyParams. Therefore, for compatibility, we turn
+  // the "restricted" RSA-PSS public key to a "generic" RSA public key.
+  RSA* rsa_params = EVP_PKEY_get1_RSA(pub_key.get());
+  if (rsa_params != nullptr) {
+    // Ideally, we should check if it is a RSA_PSS key. However, BoringSSL does
+    // not implement RSA_get0_pss_params yet.
+    bssl::UniquePtr<EVP_PKEY> rsa_key(EVP_PKEY_new());
+    EVP_PKEY_assign_RSA(rsa_key.get(), rsa_params);
+    return rsa_key;
+  }
+  return pub_key;
+}
+
+std::string X509NamePrintEx(const X509_NAME* name, uint64_t flags) {
+  if (!name) return "";
+
+  bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem()));
+  X509_NAME_print_ex(bio.get(), name, 0, flags);
+  int size = BIO_pending(bio.get());
+  std::string buffer(size, '\0');
+  int bytes_read = BIO_read(bio.get(), buffer.data(), size);
+  if (bytes_read != size) {
+    return "";
+  }
+  return buffer;
+}
+
+}  // namespace
+
+std::string X509Certificate::GetSubject() const {
+  return X509NamePrintEx(X509_get_subject_name(cert_.get()), XN_FLAG_RFC2253);
+}
+
+std::string X509Certificate::GetIssuer() const {
+  return X509NamePrintEx(X509_get_issuer_name(cert_.get()), XN_FLAG_RFC2253);
+}
+
+absl::StatusOr<std::unique_ptr<X509Certificate>> X509Certificate::Create(
+    absl::string_view der) {
+  ABSL_ASSIGN_OR_RETURN(auto x509, GetCertificateFromDER(der));
+  return std::make_unique<X509Certificate>(std::move(x509));
+}
+
+absl::Status X509Certificate::IsValidC2paCertificate(bool is_leaf) const {
+  return ComplianceChecker::IsValidC2paCertificate(cert_.get(), is_leaf);
+}
+
+absl::Status X509Certificate::VerifySignature(
+    absl::string_view signature, absl::string_view data,
+    SigningAlgorithm algorithm) const {
+  bssl::UniquePtr<EVP_PKEY> pub_key(X509_get_pubkey(cert_.get()));
+  if (pub_key == nullptr) {
+    return absl::InvalidArgumentError(
+        "Certificate does not have a valid public key");
+  }
+  pub_key = MaybeConvertPssToRsa(std::move(pub_key));
+  ABSL_ASSIGN_OR_RETURN(auto pem_str, PemEncode(pub_key.get()));
+  auto builder = SignaturePemKeysetReaderBuilder(
+      SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_VERIFY);
+  auto params = GetPemKeyParams(algorithm);
+  if (!params.ok()) {
+    return absl::Status(params.status().code(),
+                        absl::StrCat(params.status().message(),
+                                     "; Failed to get PEM key params"));
+  }
+  for (const auto& param : *params) {
+    builder.Add({.serialized_key = pem_str, .parameters = param});
+  }
+  auto reader = builder.Build();
+  if (!reader.ok()) {
+    return absl::Status(
+        reader.status().code(),
+        absl::StrCat(reader.status().message(),
+                     "; Failed to construct the PEM KeysetReader"));
+  }
+  auto handle = crypto::tink::CleartextKeysetHandle::Read(*std::move(reader));
+  if (!handle.ok()) {
+    return absl::Status(handle.status().code(),
+                        absl::StrCat(handle.status().message(),
+                                     "; Failed to construct the KeysetHandle"));
+  }
+  auto verifier = (*handle)->GetPrimitive<crypto::tink::PublicKeyVerify>(
+      crypto::tink::ConfigSignatureV0());
+  if (!verifier.ok()) {
+    return absl::Status(
+        verifier.status().code(),
+        absl::StrCat(verifier.status().message(),
+                     "; Failed to construct the PublicKeyVerify primitive"));
+  }
+  auto status = (*verifier)->Verify(signature, data);
+  if (!status.ok()) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "Tink verification failed with the status error: ", status.message()));
+  }
+  return absl::OkStatus();
+}
+
+std::string X509Certificate::DebugString() const {
+  bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem()));
+  if (!X509_print(bio.get(), cert_.get())) {
+    return "<X509_print failed>";
+  }
+  const uint8_t* data;
+  size_t len;
+  if (!BIO_mem_contents(bio.get(), &data, &len) || len == 0) {
+    return "BIO_mem_contents failed";
+  }
+  return std::string(reinterpret_cast<const char*>(data), len);
+}
+
+absl::StatusOr<absl::Time> X509Certificate::StartTime() const {
+  const ASN1_TIME* not_before = X509_get0_notBefore(cert_.get());
+  if (not_before == nullptr) {
+    return absl::InternalError("failed to get certificate start time");
+  }
+  int64_t posix_time;
+  if (ASN1_TIME_to_posix(not_before, &posix_time) == 0) {
+    return absl::InternalError("Invalid certificate start time");
+  }
+  return absl::FromUnixSeconds(posix_time);
+}
+
+absl::StatusOr<absl::Time> X509Certificate::EndTime() const {
+  int64_t posix_time;
+  const ASN1_TIME* not_after = X509_get0_notAfter(cert_.get());
+  if (not_after == nullptr) {
+    return absl::InternalError("failed to get certificate end time");
+  }
+  if (ASN1_TIME_to_posix(not_after, &posix_time) == 0) {
+    return absl::InternalError("Invalid certificate end time");
+  }
+  return absl::FromUnixSeconds(posix_time);
+}
+
+absl::StatusOr<std::vector<PemKeyParams>> X509Certificate::GetPemKeyParams(
+    SigningAlgorithm algorithm) const {
+  bssl::UniquePtr<EVP_PKEY> pub_key(X509_get_pubkey(cert_.get()));
+  if (pub_key == nullptr) {
+    return absl::InvalidArgumentError(
+        "Certificate does not have a valid public key");
+  }
+  ABSL_RETURN_IF_ERROR(CheckCompatibleWithAlgorithm(pub_key.get(), algorithm));
+  size_t key_size = EVP_PKEY_bits(pub_key.get());
+  switch (algorithm) {
+    case SigningAlgorithm::kPs256:
+      return std::vector<PemKeyParams>{PemKeyParams{
+          .key_type = crypto::tink::PemKeyType::PEM_RSA,
+          .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS,
+          .key_size_in_bits = key_size,
+          .hash_type = google::crypto::tink::HashType::SHA256,
+      }};
+    case SigningAlgorithm::kPs384:
+      return std::vector<PemKeyParams>{PemKeyParams{
+          .key_type = crypto::tink::PemKeyType::PEM_RSA,
+          .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS,
+          .key_size_in_bits = key_size,
+          .hash_type = google::crypto::tink::HashType::SHA384,
+      }};
+    case SigningAlgorithm::kPs512:
+      return std::vector<PemKeyParams>{PemKeyParams{
+          .key_type = crypto::tink::PemKeyType::PEM_RSA,
+          .algorithm = crypto::tink::PemAlgorithm::RSASSA_PSS,
+          .key_size_in_bits = key_size,
+          .hash_type = google::crypto::tink::HashType::SHA512,
+      }};
+    case SigningAlgorithm::kEs256:
+      return std::vector<PemKeyParams>{
+          PemKeyParams{
+              .key_type = crypto::tink::PemKeyType::PEM_EC,
+              .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE,
+              .key_size_in_bits = key_size,
+              .hash_type = google::crypto::tink::HashType::SHA256,
+          },
+          PemKeyParams{
+              .key_type = crypto::tink::PemKeyType::PEM_EC,
+              .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER,
+              .key_size_in_bits = key_size,
+              .hash_type = google::crypto::tink::HashType::SHA256,
+          }};
+    case SigningAlgorithm::kEs384:
+      return std::vector<PemKeyParams>{
+          PemKeyParams{
+              .key_type = crypto::tink::PemKeyType::PEM_EC,
+              .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE,
+              .key_size_in_bits = key_size,
+              .hash_type = google::crypto::tink::HashType::SHA384,
+          },
+          PemKeyParams{
+              .key_type = crypto::tink::PemKeyType::PEM_EC,
+              .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER,
+              .key_size_in_bits = key_size,
+              .hash_type = google::crypto::tink::HashType::SHA384,
+          }};
+    case SigningAlgorithm::kEs512:
+      return std::vector<PemKeyParams>{
+          PemKeyParams{
+              .key_type = crypto::tink::PemKeyType::PEM_EC,
+              .algorithm = crypto::tink::PemAlgorithm::ECDSA_IEEE,
+              .key_size_in_bits = key_size,
+              .hash_type = google::crypto::tink::HashType::SHA512,
+          },
+          PemKeyParams{
+              .key_type = crypto::tink::PemKeyType::PEM_EC,
+              .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER,
+              .key_size_in_bits = key_size,
+              .hash_type = google::crypto::tink::HashType::SHA512,
+          },
+      };
+    case SigningAlgorithm::kEdDsa:
+      return std::vector<PemKeyParams>{PemKeyParams{
+          .key_type = crypto::tink::PemKeyType::PEM_EC,
+          .algorithm = crypto::tink::PemAlgorithm::ED25519,
+          .key_size_in_bits = key_size,
+          .hash_type = google::crypto::tink::HashType::SHA512,
+      }};
+  }
+}
+
+absl::StatusOr<std::string> X509Certificate::GetSerialNumberHex() const {
+  const ASN1_INTEGER* serial = X509_get0_serialNumber(cert_.get());
+  if (serial == nullptr) {
+    return absl::InternalError("failed to get certificate serial number");
+  }
+  bssl::UniquePtr<BIGNUM> bn(ASN1_INTEGER_to_BN(serial, nullptr));
+  if (!bn) {
+    return absl::InternalError("failed to convert serial number to BIGNUM");
+  }
+  bssl::UniquePtr<char> hex(BN_bn2hex(bn.get()));
+  if (!hex) {
+    return absl::InternalError("failed to convert BIGNUM to hex");
+  }
+  return absl::AsciiStrToLower(hex.get());
+}
+
+absl::StatusOr<std::string> X509Certificate::GetAssuranceLevel() const {
+  static const ASN1_OBJECT* const assurance_level_oid = []() {
+    return OBJ_txt2obj(kAssuranceLevelOid, /*dont_search_names=*/1);
+  }();
+  if (assurance_level_oid == nullptr) {
+    return absl::InternalError("Failed to parse assurance level OID");
+  }
+
+  int index = X509_get_ext_by_OBJ(cert_.get(), assurance_level_oid, -1);
+  if (index == -1) {
+    return absl::NotFoundError("assurance level extension not found");
+  }
+
+  const X509_EXTENSION* extension = X509_get_ext(cert_.get(), index);
+  if (extension == nullptr) {
+    return absl::NotFoundError("cannot extract assurance level extension");
+  }
+
+  const ASN1_OCTET_STRING* octet_str = X509_EXTENSION_get_data(extension);
+  if (octet_str == nullptr) {
+    return absl::NotFoundError("cannot extract assurance level extension data");
+  }
+
+  const unsigned char* p = ASN1_STRING_get0_data(octet_str);
+  int64_t len = ASN1_STRING_length(octet_str);
+  bssl::UniquePtr<ASN1_OBJECT> val_obj(d2i_ASN1_OBJECT(nullptr, &p, len));
+  if (!val_obj) {
+    return absl::NotFoundError("cannot parse assurance level value");
+  }
+
+  char buf[128];
+  int res = OBJ_obj2txt(buf, sizeof(buf), val_obj.get(), /*no_name=*/1);
+  if (res <= 0) {
+    return absl::NotFoundError(
+        "cannot convert assurance level value to string");
+  }
+  return std::string(buf);
+}
+
+absl::StatusOr<std::string> X509Certificate::GetConformingProductId() const {
+  static const ASN1_OBJECT* const conformance_record_oid = []() {
+    return OBJ_txt2obj(kConformanceRecordOid, /*dont_search_names=*/1);
+  }();
+  if (conformance_record_oid == nullptr) {
+    return absl::InternalError("Failed to parse conformance record OID");
+  }
+
+  int index = X509_get_ext_by_OBJ(cert_.get(), conformance_record_oid, -1);
+  if (index == -1) {
+    return absl::NotFoundError("Conformance record extension not found");
+  }
+
+  const X509_EXTENSION* extension = X509_get_ext(cert_.get(), index);
+  if (extension == nullptr) {
+    return absl::NotFoundError(
+        "Failed to retrieve conformance record extension");
+  }
+
+  const ASN1_OCTET_STRING* octet_str = X509_EXTENSION_get_data(extension);
+  if (octet_str == nullptr) {
+    return absl::NotFoundError("Conformance record extension data is null");
+  }
+
+  const unsigned char* p = ASN1_STRING_get0_data(octet_str);
+  int64_t len = ASN1_STRING_length(octet_str);
+  bssl::UniquePtr<ASN1_UTF8STRING> utf8_str(
+      d2i_ASN1_UTF8STRING(nullptr, &p, len));
+  if (!utf8_str) {
+    return absl::NotFoundError("Failed to parse UUID as UTF8String");
+  }
+
+  return std::string(
+      reinterpret_cast<const char*>(ASN1_STRING_get0_data(utf8_str.get())),
+      ASN1_STRING_length(utf8_str.get()));
+}
+
+}  // namespace credentio
diff --git a/crypto/default/x509_certificate.h b/crypto/default/x509_certificate.h
new file mode 100644
index 0000000..13279cb
--- /dev/null
+++ b/crypto/default/x509_certificate.h
@@ -0,0 +1,66 @@
+// 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_CRYPTO_DEFAULT_X509_CERTIFICATE_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_X509_CERTIFICATE_H_
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "crypto/algorithms.h"
+#include "openssl/base.h"
+#include "openssl/x509.h"
+#include "tink/signature/signature_pem_keyset_reader.h"
+
+namespace credentio {
+
+// Class encapsulating the OpenSSL X.509 certificate.
+class X509Certificate {
+ public:
+  explicit X509Certificate(bssl::UniquePtr<X509> cert)
+      : cert_(std::move(cert)) {}
+  std::string GetSubject() const;
+  std::string GetIssuer() const;
+  std::string DebugString() const;
+  static absl::StatusOr<std::unique_ptr<X509Certificate>> Create(
+      absl::string_view der);
+  absl::Status IsValidC2paCertificate(bool is_leaf) const;
+  // Verifies a signature using the public key encapsulated by this
+  // `X509Certificate` instance.
+  // Returns an OK status if the verification is successful.
+  absl::Status VerifySignature(absl::string_view signature,
+                               absl::string_view data,
+                               SigningAlgorithm algorithm) const;
+  absl::StatusOr<absl::Time> StartTime() const;
+  absl::StatusOr<absl::Time> EndTime() const;
+  absl::StatusOr<std::vector<crypto::tink::PemKeyParams>> GetPemKeyParams(
+      SigningAlgorithm algorithm) const;
+  absl::StatusOr<std::string> GetSerialNumberHex() const;
+  absl::StatusOr<std::string> GetAssuranceLevel() const;
+  absl::StatusOr<std::string> GetConformingProductId() const;
+
+ private:
+  bssl::UniquePtr<X509> cert_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_DEFAULT_X509_CERTIFICATE_H_
diff --git a/crypto/default/x509_certificate_test.cc b/crypto/default/x509_certificate_test.cc
new file mode 100644
index 0000000..0cd976e
--- /dev/null
+++ b/crypto/default/x509_certificate_test.cc
@@ -0,0 +1,422 @@
+// 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 "crypto/default/x509_certificate.h"
+
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "crypto/algorithms.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/mem.h"
+#include "openssl/obj.h"
+#include "openssl/x509.h"
+#include "proto/common.pb.h"
+#include "tink/signature/signature_pem_keyset_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::NotNull;
+using ::testing::SizeIs;
+
+constexpr absl::string_view kDerRsaBase64 =
+    "MIIGDDCCA/SgAwIBAgIQf/"
+    "J0H9HG3kO1lHqOZXCenjANBgkqhkiG9w0BAQsFADB1MQswCQYDVQQGEwJVUzEjMCEGA1UEChMa"
+    "QWRvYmUgU3lzdGVtcyBJbmNvcnBvcmF0ZWQxHTAbBgNVBAsTFEFkb2JlIFRydXN0IFNlcnZpY2"
+    "VzMSIwIAYDVQQDExlBZG9iZSBQcm9kdWN0IFNlcnZpY2VzIEczMB4XDTI0MDExMTAwMDAwMFoX"
+    "DTI1MDExMDIzNTk1OVowfzERMA8GA1UEAwwIY2FpLXByb2QxEzARBgNVBAoMCkFkb2JlIEluYy"
+    "4xETAPBgNVBAcMCFNhbiBKb3NlMRMwEQYDVQQIDApDYWxpZm9ybmlhMQswCQYDVQQGEwJVUzEg"
+    "MB4GCSqGSIb3DQEJARYRY2FpLW9wc0BhZG9iZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDw"
+    "AwggEKAoIBAQDv0wCnfYY9lkHDsykrTG7FYlnYHBcyualuD5u9ULjzv+"
+    "FsTWOLg8Nhrz9RTxI9EeBbu3Wm7f4vqPsQYfxkEp0wIa6DVmEOZhbAeRfbhybWQ/"
+    "D4Wvvck4mEZR+QZmTpIn7HX61gbtMFHlJUxkYBctIoAt9TBAFbz9d5Ig2WG0bsH39b6/"
+    "8USXnIIk4FB6dtBRT1xeEwSDtwOd1YBpXXNBJmRWHXNJZZ85of2tBTesg/"
+    "kMvTrOJJxYDZ97U416wcvbVomlees+NXUY3/"
+    "ZB4xH+"
+    "NsZils7m1bL2v2hSnHiLoqZeUKklbHUmT4ZpjrZCdtuIsWm5j2PPXKr7HKUuCbZSXBAgMBAAWj"
+    "ggGMMIIBiDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/"
+    "wQEAwIHgDAeBgNVHSUEFzAVBgkqhkiG9y8BAQwGCCsGAQUFBwMEMIGOBgNVHSAEgYYwgYMwgYA"
+    "GCSqGSIb3LwECAzBzMHEGCCsGAQUFBwICMGUMY1lvdSBhcmUgbm90IHBlcm1pdHRlZCB0byB1c"
+    "2UgdGhpcyBMaWNlbnNlIENlcnRpZmljYXRlIGV4Y2VwdCBhcyBwZXJtaXR0ZWQgYnkgdGhlIGx"
+    "pY2Vuc2UgYWdyZWVtZW50LjBdBgNVHR8EVjBUMFKgUKBOhkxodHRwOi8vcGtpLWNybC5zeW1hd"
+    "XRoLmNvbS9jYV83YTVjM2EwYzczMTE3NDA2YWRkMTkzMTJiYzFiYzIzZi9MYXRlc3RDUkwuY3J"
+    "sMDcGCCsGAQUFBwEBBCswKTAnBggrBgEFBQcwAYYbaHR0cDovL3BraS1vY3NwLnN5bWF1dGguY"
+    "29tMB8GA1UdIwQYMBaAFFcpejJNzP7kNU7AHyRzznNTq99qMA0GCSqGSIb3DQEBCwUAA4ICAQA"
+    "hY9X822Lvow9+KlSdkAI/95WsS/"
+    "n1Y5se5YJkePUHLxsablSaNOA9BiVfJOPxcT+JjbumGCseDSmIOxYPO7mzwHpMqRoyZS+"
+    "C2T5SC6TJtt920ELoq+"
+    "0aDLjRw7gOQRSg8OK0PFPWKBtCp0qiAChUqoMRWqcX4IVBDO1sZxmEUj1cpOwJDB/"
+    "2HDsuEkKjqMA0EgAE3JC1LBYik95IQmE9WBYvIp6mBDEkyovqyBhnEar2kJJCfaBLylp/"
+    "JAUsHJnAQEP8PHK1FlkvpvdleoX59lmwT1+wHGlxYWK/"
+    "ykW+AFA60LOVJt8byjtuAE8lebuYADyPe3eEMR4THQbHwH/"
+    "1rz2nNN5tZWsPdIR7S5mX0RtUL74XgSixEkV1jDO4/"
+    "WIk7V3kjdXEZr0WtWr8VgEY5G+"
+    "8NpPBqd2LMLBp7jejMIj3tA8RS1L6MnO3PWRqkdwGwsCHsvGcTzauWarWji990WxBTPscSk6Q8"
+    "WTIqiHI9OfUUiriWQkpfeMEDo1r+zHJRmj26+"
+    "zgID7QB41xNeV3K6BFQX6ayl1swuIh26KB0Mu74sndRzG+8tMTffvjBoO9QPW/"
+    "OtdWyYmsLGT0Kcj9EaaIg1JmgNtRBwo9f89eYJSYwenQ+GddpqdnBIjk63Q+K3gCdlpTs++"
+    "tJCZ7DX9yndmYlx94HgUZFPcNimun6Q==";
+
+constexpr absl::string_view kDerEcdsaBase64 =
+    "MIIChzCCAi6gAwIBAgIUcCTmJHYF8dZfG0d1UdT6/"
+    "LXtkeYwCgYIKoZIzj0EAwIwgYwxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBww"
+    "JU29tZXdoZXJlMScwJQYDVQQKDB5DMlBBIFRlc3QgSW50ZXJtZWRpYXRlIFJvb3QgQ0ExGTAXB"
+    "gNVBAsMEEZPUiBURVNUSU5HX09OTFkxGDAWBgNVBAMMD0ludGVybWVkaWF0ZSBDQTAeFw0yMjA"
+    "2MTAxODQ2NDBaFw0zMDA4MjYxODQ2NDBaMIGAMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExE"
+    "jAQBgNVBAcMCVNvbWV3aGVyZTEfMB0GA1UECgwWQzJQQSBUZXN0IFNpZ25pbmcgQ2VydDEZMBc"
+    "GA1UECwwQRk9SIFRFU1RJTkdfT05MWTEUMBIGA1UEAwwLQzJQQSBTaWduZXIwWTATBgcqhkjOP"
+    "QIBBggqhkjOPQMBBwNCAAQPaL6RkAkYkKU4+IryBSYxJM3h77sFiMrbvbI8fG7w2Bbl9otNG/"
+    "cch3DAw5rGAPV7NWkyl3QGuV/wt0MrAPDoo3gwdjAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/"
+    "wQMMAoGCCsGAQUFBwMEMA4GA1UdDwEB/"
+    "wQEAwIGwDAdBgNVHQ4EFgQUFznP0y83joiNOCedQkxTtAMyNcowHwYDVR0jBBgwFoAUDnyNcma"
+    "/osnlAJTvtW6A4rYOL2swCgYIKoZIzj0EAwIDRwAwRAIgOY/2szXjslg/"
+    "MyJFZ2y7OH8giPYTsvS7UPRP9GI9NgICIDQPMKrELQUJEtipZ0TqvI/"
+    "4mieoyRCeIiQtyuS0LACz";
+
+// Base64 encoded string of DER bytes of an X.509 certificate signed with
+// Ed25519 algorithm, which is generated by following commands:
+//
+// openssl genpkey -algorithm Ed25519 -outform PEM -out private.pem
+// openssl pkey -in private.pem -pubout -outform PEM -out public.pem
+// openssl req -x509 -key private.pem -out cert.pem -days 99999
+// openssl x509 -in cert.pem -outform DER -out cert.der
+// cat cert.der | base64
+//
+// This certificate is self-signed on 2024-09-18 and it will expire on
+// 2298-07-03. So, if this code is still around in 2298 and you wonder why your
+// test that relies on this certificate being valid fails, please update your
+// test certificate.
+
+constexpr absl::string_view kDerEdBase64 =
+    "MIIBoTCCAVOgAwIBAgIUZnrWsn3k+"
+    "VyZMdOt2jHxA4Z6QNIwBQYDK2VwMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXR"
+    "lMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjQwOTE4MTkxMTMyWhgPM"
+    "jI5ODA3MDMxOTExMzJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwY"
+    "DVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwKjAFBgMrZXADIQAYkLGZHs4BuUq11RLkR"
+    "V3lX6U1CMxpkKMI3dFBkQjDFKNTMFEwHQYDVR0OBBYEFPl9A331I54qf4vhLTs5KZqsJznSMB8"
+    "GA1UdIwQYMBaAFPl9A331I54qf4vhLTs5KZqsJznSMA8GA1UdEwEB/"
+    "wQFMAMBAf8wBQYDK2VwA0EAxWKyUVcH4hoW/"
+    "mS66Ot4UGqaEKUANiyT6khvcUZLUpM2m7Cmh7ZwmjBz7b6X9nqHwd+8WnSMUZHKL5bLmZmsDw="
+    "=";
+
+// Base64 encoded string of the signature obtained by running the following
+// commands:
+//
+// openssl pkeyutl -sign -inkey private.pem -rawin -in data.txt -out sig.bin
+// cat sig.bin | base64
+//
+// where data.txt contains the text "test data" (without a new line).
+constexpr absl::string_view kEdSignatureBase64 =
+    "wB5+rUKWp47TZ8VPpUlA32IO033ggYB63qXLPyb8bDEGJyD8TBM/"
+    "psRxl5Z8EsglhzdmvB29VL62xp4+3On/Bg==";
+
+std::string Base64Unescape(absl::string_view base64) {
+  std::string bytes;
+  CHECK(absl::Base64Unescape(base64, &bytes));
+  return bytes;
+}
+
+TEST(X509CertificateTest, GetPemKeyParamsForRsaKey) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  std::vector<crypto::tink::PemKeyParams> params;
+
+  auto params_ps256 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs256);
+  ASSERT_THAT(params_ps256.status(), IsOk());
+  params = *params_ps256;
+  ASSERT_THAT(params, SizeIs(1));
+  EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA);
+  EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS);
+  EXPECT_EQ(params[0].key_size_in_bits, 2048);
+  EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA256);
+
+  auto params_ps384 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs384);
+  ASSERT_THAT(params_ps384.status(), IsOk());
+  params = *params_ps384;
+  ASSERT_THAT(params, SizeIs(1));
+  EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA);
+  EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS);
+  EXPECT_EQ(params[0].key_size_in_bits, 2048);
+  EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA384);
+
+  auto params_ps512 = (*x509)->GetPemKeyParams(SigningAlgorithm::kPs512);
+  ASSERT_THAT(params_ps512.status(), IsOk());
+  params = *params_ps512;
+  ASSERT_THAT(params, SizeIs(1));
+  EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_RSA);
+  EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::RSASSA_PSS);
+  EXPECT_EQ(params[0].key_size_in_bits, 2048);
+  EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512);
+
+  EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEs256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Wrong key type")));
+  EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Wrong key type")));
+}
+
+TEST(X509CertificateTest, GetPemKeyParamsForEcdsaKey) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerEcdsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  std::vector<crypto::tink::PemKeyParams> params;
+
+  auto params_es256 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs256);
+  ASSERT_THAT(params_es256.status(), IsOk());
+  params = *params_es256;
+  ASSERT_THAT(params, SizeIs(2));
+  EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC);
+  EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE);
+  EXPECT_EQ(params[0].key_size_in_bits, 256);
+  EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA256);
+  EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC);
+  EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER);
+  EXPECT_EQ(params[1].key_size_in_bits, 256);
+  EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA256);
+
+  auto params_es384 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs384);
+  ASSERT_THAT(params_es384.status(), IsOk());
+  params = *params_es384;
+  ASSERT_THAT(params, SizeIs(2));
+  EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC);
+  EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE);
+  EXPECT_EQ(params[0].key_size_in_bits, 256);
+  EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA384);
+  EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC);
+  EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER);
+  EXPECT_EQ(params[1].key_size_in_bits, 256);
+  EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA384);
+
+  auto params_es512 = (*x509)->GetPemKeyParams(SigningAlgorithm::kEs512);
+  ASSERT_THAT(params_es512.status(), IsOk());
+  params = *params_es512;
+  ASSERT_THAT(params, SizeIs(2));
+  EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC);
+  EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ECDSA_IEEE);
+  EXPECT_EQ(params[0].key_size_in_bits, 256);
+  EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512);
+  EXPECT_EQ(params[1].key_type, crypto::tink::PemKeyType::PEM_EC);
+  EXPECT_EQ(params[1].algorithm, crypto::tink::PemAlgorithm::ECDSA_DER);
+  EXPECT_EQ(params[1].key_size_in_bits, 256);
+  EXPECT_EQ(params[1].hash_type, google::crypto::tink::HashType::SHA512);
+
+  EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Wrong key type")));
+  EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Wrong key type")));
+}
+
+TEST(X509CertificateTest, GetPemKeyParamsForEdKey) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  std::vector<crypto::tink::PemKeyParams> params;
+
+  auto params_eddsa = (*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa);
+  ASSERT_THAT(params_eddsa.status(), IsOk());
+  params = *params_eddsa;
+  ASSERT_THAT(params, SizeIs(1));
+  EXPECT_EQ(params[0].key_type, crypto::tink::PemKeyType::PEM_EC);
+  EXPECT_EQ(params[0].algorithm, crypto::tink::PemAlgorithm::ED25519);
+  // BoringSSL will return 253 bits here instead of 256 bits because the lower
+  // three bits of the private key scalar are cleared.
+  EXPECT_EQ(params[0].key_size_in_bits, 253);
+  EXPECT_EQ(params[0].hash_type, google::crypto::tink::HashType::SHA512);
+
+  EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Wrong key type")));
+  EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEs256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Wrong key type")));
+}
+TEST(X509CertificateTest, VerifyEdSignedData) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  EXPECT_THAT(
+      (*x509)->VerifySignature(Base64Unescape(kEdSignatureBase64),
+                               "this is a test", SigningAlgorithm::kEdDsa),
+      IsOk());
+}
+
+TEST(X509CertificateTest, VerifySignatureWithRsaKey) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  // The actual signature and data don't matter for covering the lines in
+  // MaybeConvertPssToRsa, as the key conversion happens before signature
+  // verification. We expect a failure because the signature is invalid.
+  EXPECT_THAT((*x509)->VerifySignature("fake_signature", "fake_data",
+                                       SigningAlgorithm::kPs256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Tink verification failed")));
+}
+
+TEST(X509CertificateTest, GetStartTime) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  auto start_time = (*x509)->StartTime();
+  ASSERT_THAT(start_time.status(), IsOk());
+  EXPECT_EQ(absl::ToUnixSeconds(*start_time), 1704931200);
+}
+
+TEST(X509CertificateTest, GetEndTime) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  auto end_time = (*x509)->EndTime();
+  ASSERT_THAT(end_time.status(), IsOk());
+  EXPECT_EQ(absl::ToUnixSeconds(*end_time), 1736553599);
+}
+
+TEST(X509CertificateTest, GetSubjectIssuerNullName) {
+  bssl::UniquePtr<X509> x509(X509_new());
+  X509Certificate cert(std::move(x509));
+  EXPECT_THAT(cert.GetSubject(), IsEmpty());
+  EXPECT_THAT(cert.GetIssuer(), IsEmpty());
+}
+
+TEST(X509CertificateTest, GetSerialNumberHexRsa) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  auto serial_hex = (*x509)->GetSerialNumberHex();
+  ASSERT_THAT(serial_hex.status(), IsOk());
+  EXPECT_EQ(*serial_hex, "7ff2741fd1c6de43b5947a8e65709e9e");
+}
+
+TEST(X509CertificateTest, GetSerialNumberHexEcdsa) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerEcdsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  auto serial_hex = (*x509)->GetSerialNumberHex();
+  ASSERT_THAT(serial_hex.status(), IsOk());
+  EXPECT_EQ(*serial_hex, "7024e6247605f1d65f1b477551d4fafcb5ed91e6");
+}
+
+TEST(X509CertificateTest, GetSerialNumberHexEdDsa) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  auto serial_hex = (*x509)->GetSerialNumberHex();
+  ASSERT_THAT(serial_hex.status(), IsOk());
+  EXPECT_EQ(*serial_hex, "667ad6b27de4f95c9931d3adda31f103867a40d2");
+}
+
+TEST(X509CertificateTest, GetAssuranceLevelNotFound) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  EXPECT_THAT((*x509)->GetAssuranceLevel(),
+              StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(X509CertificateTest, GetAssuranceLevelCorrectlyDecodesExtension) {
+  constexpr absl::string_view kAssuranceLevelValueOid =
+      "1.3.6.1.4.1.62558.3.20";
+  constexpr absl::string_view kAssuranceLevelExtensionOid =
+      "1.3.6.1.4.1.62558.3";
+  bssl::UniquePtr<X509> x509(X509_new());
+  ASSERT_NE(x509, nullptr);
+
+  bssl::UniquePtr<ASN1_OBJECT> ext_oid(
+      OBJ_txt2obj(kAssuranceLevelExtensionOid.data(), /*dont_search_names=*/1));
+  ASSERT_NE(ext_oid, nullptr);
+
+  bssl::UniquePtr<ASN1_OBJECT> val_oid(
+      OBJ_txt2obj(kAssuranceLevelValueOid.data(), /*dont_search_names=*/1));
+  ASSERT_NE(val_oid, nullptr);
+
+  unsigned char* der_data = nullptr;
+  int der_len = i2d_ASN1_OBJECT(val_oid.get(), &der_data);
+  ASSERT_GT(der_len, 0);
+
+  bssl::UniquePtr<ASN1_OCTET_STRING> octet_str(ASN1_OCTET_STRING_new());
+  ASSERT_TRUE(ASN1_OCTET_STRING_set(octet_str.get(), der_data, der_len));
+  OPENSSL_free(der_data);
+
+  bssl::UniquePtr<X509_EXTENSION> ext(X509_EXTENSION_create_by_OBJ(
+      nullptr, ext_oid.get(), /*crit=*/0, octet_str.get()));
+  ASSERT_NE(ext, nullptr);
+  ASSERT_TRUE(X509_add_ext(x509.get(), ext.get(), -1));
+
+  X509Certificate cert(std::move(x509));
+  EXPECT_THAT(cert.GetAssuranceLevel(), IsOkAndHolds(kAssuranceLevelValueOid));
+}
+
+TEST(X509CertificateTest, GetConformingProductIdNotFound) {
+  auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
+  ASSERT_THAT(x509.status(), IsOk());
+  EXPECT_THAT((*x509)->GetConformingProductId(),
+              StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(X509CertificateTest, GetConformingProductIdCorrectlyDecodesExtension) {
+  // Create a cert with the conforming product id extension.
+  bssl::UniquePtr<X509> x509(X509_new());
+
+  bssl::UniquePtr<ASN1_OBJECT> ext_oid(
+      OBJ_txt2obj("1.3.6.1.4.1.62558.4", /*dont_search_names=*/1));
+  ASSERT_THAT(ext_oid, NotNull());
+
+  bssl::UniquePtr<ASN1_UTF8STRING> val_str(ASN1_UTF8STRING_new());
+  constexpr absl::string_view kUuid = "c25c0e15-84dc-4cd7-95de-1d9ea4c7f0db";
+  ASSERT_TRUE(ASN1_STRING_set(val_str.get(), kUuid.data(), kUuid.size()));
+
+  unsigned char* der_data = nullptr;
+  int der_len = i2d_ASN1_UTF8STRING(val_str.get(), &der_data);
+  ASSERT_GT(der_len, 0);
+
+  bssl::UniquePtr<ASN1_OCTET_STRING> octet_str(ASN1_OCTET_STRING_new());
+  ASSERT_TRUE(ASN1_OCTET_STRING_set(octet_str.get(), der_data, der_len));
+  OPENSSL_free(der_data);
+
+  bssl::UniquePtr<X509_EXTENSION> ext(X509_EXTENSION_create_by_OBJ(
+      nullptr, ext_oid.get(), /*crit=*/0, octet_str.get()));
+  ASSERT_THAT(ext, NotNull());
+  ASSERT_TRUE(X509_add_ext(x509.get(), ext.get(), -1));
+
+  // Parse cert and extract the conforming product id.
+  X509Certificate cert(std::move(x509));
+  EXPECT_THAT(cert.GetConformingProductId(), IsOkAndHolds(kUuid));
+}
+
+TEST(X509CertificateTest, CertificateWithoutValidPublicKey) {
+  bssl::UniquePtr<X509> x509(X509_new());
+  ASSERT_NE(x509, nullptr);
+  X509Certificate cert(std::move(x509));
+  EXPECT_THAT(
+      cert.VerifySignature("sig", "data", SigningAlgorithm::kPs256),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Certificate does not have a valid public key")));
+  EXPECT_THAT(
+      cert.GetPemKeyParams(SigningAlgorithm::kPs256),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Certificate does not have a valid public key")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/hash.h b/crypto/hash.h
new file mode 100644
index 0000000..eedf3ba
--- /dev/null
+++ b/crypto/hash.h
@@ -0,0 +1,103 @@
+// 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_CRYPTO_HASH_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_HASH_H_
+
+#include <memory>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+
+namespace credentio {
+
+// A single-use object for computing a hash of some content.
+// For hash checking, use `HashChecker` instead.
+class Hasher {
+ public:
+  virtual ~Hasher() = default;
+
+  // Adds `content` to the data to be hashed. Can be called repeatedly.
+  virtual void Update(absl::string_view content) = 0;
+
+  // Returns digest of the previously provided content.
+  virtual std::string Digest() = 0;
+};
+
+// A hasher for size estimation. Returns all-zero hashes.
+class TemplatedHasher : public Hasher {
+ public:
+  explicit TemplatedHasher(HashAlgorithm algorithm) : algorithm_(algorithm) {}
+  void Update(absl::string_view content) override {}
+  std::string Digest() override {
+    return std::string(HashSizeBytes(algorithm_), '\0');
+  }
+
+ private:
+  HashAlgorithm algorithm_;
+};
+
+// A factory for creating Hashers.
+class HasherFactory {
+ public:
+  virtual ~HasherFactory() = default;
+  virtual absl::StatusOr<std::unique_ptr<Hasher>> Create() const = 0;
+  virtual HashAlgorithm algorithm() const = 0;
+};
+
+// A factory for creating Hashers for size estimation, which return all-zero
+// hashes.
+class TemplatedHasherFactory : public HasherFactory {
+ public:
+  explicit TemplatedHasherFactory(HashAlgorithm algorithm)
+      : algorithm_(algorithm) {}
+  absl::StatusOr<std::unique_ptr<Hasher>> Create() const override {
+    return std::make_unique<TemplatedHasher>(algorithm_);
+  }
+  HashAlgorithm algorithm() const override { return algorithm_; }
+
+ private:
+  HashAlgorithm algorithm_;
+};
+
+// A single-use checker for checking content against an expected hash.
+class HashChecker {
+ public:
+  virtual ~HashChecker() = default;
+
+  // Adds `content` to the data to be hashed. Can be called repeatedly.
+  virtual void Update(absl::string_view content) = 0;
+
+  // Returns true if the hash of the previously provided content matches
+  // `expected_hash`.
+  virtual bool Check(absl::string_view expected_hash) = 0;
+};
+
+// A factory for creating `HashChecker`s.
+class HashCheckerFactory {
+ public:
+  virtual ~HashCheckerFactory() = default;
+
+  // Returns a `HashChecker` for `algorithm` or an error if the algorithm is not
+  // supported.
+  virtual absl::StatusOr<std::unique_ptr<HashChecker>> Create(
+      HashAlgorithm algorithm) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_HASH_H_
diff --git a/crypto/mock_hash.h b/crypto/mock_hash.h
new file mode 100644
index 0000000..37ad463
--- /dev/null
+++ b/crypto/mock_hash.h
@@ -0,0 +1,57 @@
+// 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_CRYPTO_MOCK_HASH_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_MOCK_HASH_H_
+
+#include <memory>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+
+namespace credentio {
+
+class MockHasher : public Hasher {
+ public:
+  MOCK_METHOD(void, Update, (absl::string_view content), (override));
+  MOCK_METHOD(std::string, Digest, (), (override));
+};
+
+class MockHasherFactory : public HasherFactory {
+ public:
+  MOCK_METHOD(absl::StatusOr<std::unique_ptr<Hasher>>, Create, (),
+              (const, override));
+  MOCK_METHOD(HashAlgorithm, algorithm, (), (const, override));
+};
+
+class MockHashChecker : public HashChecker {
+ public:
+  MOCK_METHOD(void, Update, (absl::string_view content), (override));
+  MOCK_METHOD(bool, Check, (absl::string_view expected_hash), (override));
+};
+
+class MockHashCheckerFactory : public HashCheckerFactory {
+ public:
+  MOCK_METHOD(absl::StatusOr<std::unique_ptr<HashChecker>>, Create,
+              (HashAlgorithm algorithm), (const, override));
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_MOCK_HASH_H_
diff --git a/crypto/timestamp_read_handler.h b/crypto/timestamp_read_handler.h
new file mode 100644
index 0000000..b25a2e9
--- /dev/null
+++ b/crypto/timestamp_read_handler.h
@@ -0,0 +1,48 @@
+// 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_CRYPTO_TIMESTAMP_READ_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_READ_HANDLER_H_
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+
+// A consumer of RFC3161 TimeStampTokens.
+class TimestampReadHandler {
+ public:
+  // Validates an RFC 3161 TimeStampToken. Checks CMS signature and certificate
+  // trustworthiness. Caller must verify that the message imprint of the
+  // returned `VerifiedTimestamp` matches the signature to which the timestamp
+  // applies.
+  //
+  // Returns:
+  //   - UNAUTHENTICATED if the TSA's certificate cannot be trusted or if the
+  //       timestamp's signature is invalid.
+  //   - INVALID_ARGUMENT if `cms` cannot be parsed.
+  //   - OUT_OF_RANGE if the TSA's certificate was not within its validity
+  //       period at the asserted time.
+  //   - UNAVAILABLE if the root trust list cannot be accessed (only possible on
+  //       platforms where it is not stored in-process.)
+  virtual absl::StatusOr<VerifiedTimestamp> VerifyTimestamp(
+      absl::string_view cms) const = 0;
+  virtual ~TimestampReadHandler() = default;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_READ_HANDLER_H_
diff --git a/external/boringssl_pki_visibility.patch b/external/boringssl_pki_visibility.patch
new file mode 100644
index 0000000..50d3acb
--- /dev/null
+++ b/external/boringssl_pki_visibility.patch
@@ -0,0 +1,9 @@
+--- a/BUILD.bazel
++++ b/BUILD.bazel
+@@ -199,5 +199,6 @@
+     hdrs = pki_headers,
+     copts = ["-DBORINGSSL_IMPLEMENTATION"],
+     internal_hdrs = pki_internal_headers,
+     deps = [":crypto"],
++    visibility = ["//visibility:public"],
+ )
diff --git a/external/libcppbor.BUILD b/external/libcppbor.BUILD
new file mode 100644
index 0000000..58d1e77
--- /dev/null
+++ b/external/libcppbor.BUILD
@@ -0,0 +1,18 @@
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+cc_library(
+    name = "libcppbor",
+    srcs = ["src/cppbor.cpp", "src/cppbor_parse.cpp"],
+    deps = ["@boringssl//:crypto"],
+    hdrs = [
+        "include/cppbor/cppbor.h",
+        "include/cppbor/cppbor_parse.h",
+        "include/cppbor/span.h",
+    ],
+    includes = ["include", "include/cppbor"],
+    copts = [
+        "-D__TRUSTY__",
+        "-include limits",
+    ],
+    visibility = ["//visibility:public"],
+)
diff --git a/formats/BUILD b/formats/BUILD
new file mode 100644
index 0000000..20f7bd9
--- /dev/null
+++ b/formats/BUILD
@@ -0,0 +1,158 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "byte_range",
+    hdrs = ["byte_range.h"],
+)
+
+cc_library(
+    name = "asset_box",
+    hdrs = ["asset_box.h"],
+    deps = [
+        ":byte_range",
+    ],
+)
+
+cc_library(
+    name = "asset_byte_info",
+    hdrs = ["asset_byte_info.h"],
+    deps = [
+        ":asset_box",
+        ":byte_range",
+    ],
+)
+
+cc_library(
+    name = "extractor_result",
+    testonly = 1,
+    hdrs = ["extractor_result.h"],
+    deps = [
+        ":asset_byte_info",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    hdrs = ["extractor.h"],
+    deps = [
+        ":asset_box",
+        ":byte_range",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    hdrs = ["assessor.h"],
+    deps = [
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
+
+cc_test(
+    name = "format_test",
+    srcs = ["format_test.cc"],
+    deps = [
+        ":assessor",
+        ":asset_box",
+        ":byte_range",
+        ":extractor",
+        ":format",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "registry",
+    srcs = ["registry.cc"],
+    hdrs = ["registry.h"],
+    deps = [
+        ":format",
+        "@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",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "registry_test",
+    srcs = ["registry_test.cc"],
+    deps = [
+        ":assessor",
+        ":asset_box",
+        ":byte_range",
+        ":extractor",
+        ":format",
+        ":registry",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "core_registry",
+    srcs = ["core_registry.cc"],
+    hdrs = ["core_registry.h"],
+    deps = [
+        ":format",
+        ":registry",
+        "//formats/bmff:format",
+        "//formats/gif:format",
+        "//formats/id3:format",
+        "//formats/jpeg:format",
+        "//formats/pdf:format",
+        "//formats/png:format",
+        "//formats/riff:format",
+        "//formats/tiff:format",
+        "//formats/zip:format",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
diff --git a/formats/assessor.h b/formats/assessor.h
new file mode 100644
index 0000000..4378b4e
--- /dev/null
+++ b/formats/assessor.h
@@ -0,0 +1,38 @@
+// 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_FORMATS_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// FormatAssessors determine if a given asset is applicable.
+class FormatAssessor {
+ public:
+  virtual ~FormatAssessor() = default;
+
+  // Returns true if the supplied asset is supported. Returns an error if the
+  // asset cannot be assessed. Will assess the input at the current position,
+  // and will return the position to the start of the input unless a failure
+  // occurs.
+  virtual absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ASSESSOR_H_
diff --git a/formats/asset_box.h b/formats/asset_box.h
new file mode 100644
index 0000000..1dc4e4a
--- /dev/null
+++ b/formats/asset_box.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_FORMATS_ASSET_BOX_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BOX_H_
+
+#include <ostream>
+#include <string>
+
+#include "formats/byte_range.h"
+
+namespace credentio {
+
+// The box and its location in the asset.
+struct AssetBox {
+  std::string identifier;
+  ByteRange byte_range;
+
+  bool operator==(const AssetBox& other) const {
+    return identifier == other.identifier && byte_range == other.byte_range;
+  }
+  bool operator!=(const AssetBox& other) const { return !(*this == other); }
+};
+
+inline std::ostream& operator<<(std::ostream& os, const AssetBox& box) {
+  return os << "AssetBox{identifier: " << box.identifier << ", byte_range: {"
+            << box.byte_range.offset << ", " << box.byte_range.length << "}}";
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BOX_H_
diff --git a/formats/asset_byte_info.h b/formats/asset_byte_info.h
new file mode 100644
index 0000000..08d0f72
--- /dev/null
+++ b/formats/asset_byte_info.h
@@ -0,0 +1,59 @@
+// 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_FORMATS_ASSET_BYTE_INFO_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BYTE_INFO_H_
+
+#include <cstddef>
+#include <ostream>
+#include <vector>
+
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+
+namespace credentio {
+
+// Metadata about the bytes in the asset.
+struct AssetByteInfo {
+  // The location of the C2PA Manifest Store in the asset.
+  ByteRange manifest_store_location;
+  // A list of the file format's native boxes/segments within the asset. This
+  // vector will be populated for file formats that are based upon box
+  // structures. Eg: JPEG, PNG
+  std::vector<AssetBox> boxes;
+
+  bool operator==(const AssetByteInfo& other) const {
+    return manifest_store_location == other.manifest_store_location &&
+           boxes == other.boxes;
+  }
+  bool operator!=(const AssetByteInfo& other) const {
+    return !(*this == other);
+  }
+};
+
+inline std::ostream& operator<<(std::ostream& os, const AssetByteInfo& info) {
+  os << "AssetByteInfo{manifest_store_location: {"
+     << info.manifest_store_location.offset << ", "
+     << info.manifest_store_location.length << "}, boxes: [";
+  for (size_t i = 0; i < info.boxes.size(); ++i) {
+    if (i > 0) os << ", ";
+    os << info.boxes[i];
+  }
+  return os << "]}";
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ASSET_BYTE_INFO_H_
diff --git a/formats/bmff/BUILD b/formats/bmff/BUILD
new file mode 100644
index 0000000..584fc68
--- /dev/null
+++ b/formats/bmff/BUILD
@@ -0,0 +1,194 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "box_header",
+    srcs = ["box_header.cc"],
+    hdrs = ["box_header.h"],
+    deps = [
+        ":constants",
+        ":xpath",
+        "//constants:labels",
+        "//formats:byte_range",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_reading",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = ["@abseil-cpp//absl/strings:string_view"],
+)
+
+cc_library(
+    name = "xpath",
+    srcs = ["xpath.cc"],
+    hdrs = ["xpath.h"],
+    deps = ["@abseil-cpp//absl/strings"],
+)
+
+cc_library(
+    name = "test_utils",
+    testonly = 1,
+    hdrs = ["test_utils.h"],
+    deps = [
+        "//constants:labels",
+        "//testing:test_string_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "xpath_test",
+    srcs = ["xpath_test.cc"],
+    deps = [
+        ":xpath",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "box_matcher",
+    srcs = ["box_matcher.cc"],
+    hdrs = ["box_matcher.h"],
+    deps = [
+        ":box_header",
+        ":xpath",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "box_matcher_test",
+    srcs = ["box_matcher_test.cc"],
+    deps = [
+        ":box_header",
+        ":box_matcher",
+        ":test_utils",
+        "//constants:labels",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":box_header",
+        "//constants:labels",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "//utils:riegeli",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    deps = [
+        ":extractor",
+        ":test_utils",
+        "//constants:labels",
+        "//formats:asset_box",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//formats:extractor_result",
+        "//testing:jumbf_utils",
+        "//testing:test_string_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        ":box_header",
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        ":test_utils",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/bmff/assessor.cc b/formats/bmff/assessor.cc
new file mode 100644
index 0000000..e17f960
--- /dev/null
+++ b/formats/bmff/assessor.cc
@@ -0,0 +1,110 @@
+// 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 "formats/bmff/assessor.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/string_view.h"
+#include "formats/bmff/box_header.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+bool IsSupportedBrand(absl::string_view brand) {
+  return brand == "qt  " || absl::StartsWith(brand, "mp4") || brand == "heic" ||
+         brand == "avif" || brand == "M4A " || absl::StartsWith(brand, "iso");
+}
+
+absl::StatusOr<std::string> ExtractBrand(riegeli::Reader& input) {
+  std::string brand;
+  if (!input.Read(4, brand)) {
+    return input.StatusOrAnnotate(absl::InternalError("failed to read brand"));
+  }
+  return brand;
+}
+
+}  // namespace
+
+absl::StatusOr<bool> BmffAssessor::IsSupported(riegeli::Reader& input) const {
+  int64_t starting_position = input.pos();
+
+  bool is_supported = false;
+  absl::Status iteration_status = IterateOverBmffBoxes(
+      input,
+      [&input,
+       &is_supported](const BmffBoxHeader& box_header) -> absl::StatusOr<bool> {
+        if (box_header.type == "ftyp") {
+          uint64_t box_end = box_header.start + box_header.box_size;
+          if (box_header.box_size < box_header.header_size + 8) {
+            return false;  // Terminate loop
+          }
+
+          ABSL_ASSIGN_OR_RETURN(auto major_brand, ExtractBrand(input));
+          if (IsSupportedBrand(major_brand)) {
+            is_supported = true;
+            return false;  // Terminate loop
+          }
+          if (!input.Skip(4)) {
+            return input.StatusOrAnnotate(
+                absl::InternalError("failed to skip minor brand"));
+          }
+
+          std::string compatible_brand;
+          while (input.pos() <= box_end && box_end - input.pos() >= 4) {
+            ABSL_ASSIGN_OR_RETURN(compatible_brand, ExtractBrand(input));
+            if (IsSupportedBrand(compatible_brand)) {
+              is_supported = true;
+              return false;  // Terminate loop
+            }
+          }
+
+          // We didn't find a supported brand.
+          return false;  // Terminate loop
+        } else if (box_header.type == "moov") {
+          // We found a moov box, confident that this file is a QuickTime Movie.
+          is_supported = true;
+          return false;  // Terminate loop
+        }
+
+        // Only process top-level atoms.
+        if (!input.Seek(box_header.start + box_header.box_size)) {
+          return input.StatusOrAnnotate(
+              absl::InternalError("failed to seek to end of box"));
+        }
+        return true;  // Continue
+      });
+
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(
+        absl::InternalError("failed to seek to start of asset"));
+  }
+  if (!iteration_status.ok() && !absl::IsDataLoss(iteration_status)) {
+    return iteration_status;
+  }
+  if (absl::IsDataLoss(iteration_status)) {
+    return false;
+  }
+  return is_supported;
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/assessor.h b/formats/bmff/assessor.h
new file mode 100644
index 0000000..29d6bef
--- /dev/null
+++ b/formats/bmff/assessor.h
@@ -0,0 +1,34 @@
+// 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_FORMATS_BMFF_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Assessor for the BMFF image format.
+class BmffAssessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_ASSESSOR_H_
diff --git a/formats/bmff/assessor_test.cc b/formats/bmff/assessor_test.cc
new file mode 100644
index 0000000..96e97fa
--- /dev/null
+++ b/formats/bmff/assessor_test.cc
@@ -0,0 +1,132 @@
+// 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 "formats/bmff/assessor.h"
+
+#include <string>
+
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "formats/bmff/test_utils.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+
+TEST(IsSupportedTest, FailsForTooFewBytes) {
+  std::string image = "a";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, FailsForNotStartingWithFtyp) {
+  std::string image = "this_is_not_a_bmff";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, ValidStartingFtype) {
+  std::string image = credentio_testing::Box("ftyp", "heictest");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidStartingMoov) {
+  std::string image = credentio_testing::Box("moov", "data");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, UnsupportedFileType) {
+  std::string image = credentio_testing::Box("ftyp", "testtest");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, SupportedFileTypeWithCompatibleBrand) {
+  std::string image = credentio_testing::Box("ftyp", "testtestmp42");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidStartingBytesAtOffset2) {
+  std::string image =
+      absl::StrCat("ab", credentio_testing::Box("ftyp", "heictest"));
+  riegeli::StringReader<> input(image);
+
+  // Invalid at 0
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+TEST(IsSupportedTest, StopsEarlyAfterFtyp) {
+  // Supported major brand followed by invalid truncated box data.
+  // Assessor should stop immediately and declare it supported, ignoring later
+  // invalid data.
+  std::string image = absl::StrCat(credentio_testing::Box("ftyp", "heictest"),
+                                   "invalid_truncated_box_data");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, StopsEarlyAfterFtypCompatibleBrand) {
+  // Supported compatible brand in ftyp followed by truncated box data should be
+  // supported.
+  std::string image =
+      absl::StrCat(credentio_testing::Box("ftyp", "testtestmp42"),
+                   "invalid_truncated_box_data");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, StopsEarlyOnUnsupportedFtypAndIgnoresMoov) {
+  // Unsupported ftyp followed by a moov box.
+  // Assessor should terminate early and return unsupported (false) since it
+  // shouldn't inspect moov.
+  std::string image = absl::StrCat(credentio_testing::Box("ftyp", "testtest"),
+                                   credentio_testing::Box("moov", "data"));
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, StopsEarlyAfterMoov) {
+  // Supported moov box followed by invalid truncated box data.
+  // Assessor should terminate early indicating supported (true).
+  std::string image = absl::StrCat(credentio_testing::Box("moov", "data"),
+                                   "invalid_truncated_box_data");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, FtypBoxTooSmallForBrandsDoesNotUnderflow) {
+  // ftyp box size of 8 bytes followed by data containing a supported brand "qt
+  // "
+  std::string image =
+      absl::StrCat(credentio_testing::BadBox("ftyp", "", 8), "unkn0000qt  ");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(BmffAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/bmff/box_header.cc b/formats/bmff/box_header.cc
new file mode 100644
index 0000000..8f7f7d7
--- /dev/null
+++ b/formats/bmff/box_header.cc
@@ -0,0 +1,403 @@
+// 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 "formats/bmff/box_header.h"
+
+#include <cstdint>
+#include <limits>
+#include <optional>
+#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/match.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/bmff/constants.h"
+#include "formats/bmff/xpath.h"
+#include "formats/byte_range.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_reading.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+
+namespace {
+
+// Returns true if the given type is "uuid".
+inline bool IsUuid(absl::string_view type) { return type == "uuid"; }
+
+// ISOBMFF and QTFF define "meta" boxes differently. ISOBMFF defines it
+// as a full box with flags and version (see 4.2 'Object Structure' in ISO/IEC
+// 14496-12:2005), while QuickTime does not have such a full box structure.
+// Here, we check the 8 bytes and see if it is the "hdlr" box, as defined by
+// https://developer.apple.com/documentation/quicktime-file-format/metadata_atom.
+bool IsQuickTimeMetaBox(const BmffBoxHeader& header, riegeli::Reader& input) {
+  if (header.type != "meta") {
+    return false;
+  }
+  // The next 8 bytes can be either:
+  // (iso) [1 byte version + 3 bytes flags][4 byte size of next atom]
+  // (qt)  [4 byte size of next atom      ][4 byte hdlr atom type   ]
+  auto pin = input.pos();
+  bool success = input.Seek(pin + 4);
+  std::string type;
+  success &= input.Read(4, type);
+  success &= input.Seek(pin);
+  return success && type == "hdlr";
+};
+
+constexpr int kAtomTypeSize = 4;
+constexpr int kVersionFlagsSize = 4;
+constexpr int kSizeOf32BitSize = 4;
+constexpr int kSizeOf64BitSize = 8;
+
+bool ShouldWriteSize64Bits(uint64_t box_size) {
+  return box_size > std::numeric_limits<uint32_t>::max();
+}
+
+bool ShouldWriteUserType(const BmffBox& box) { return IsUuid(box.type); }
+
+bool ShouldWriteVersionFlags(const BmffBox& box) {
+  return IsBmffFullBox(box.type) || IsUuid(box.type);
+}
+
+constexpr int kMaxBmffDepth = 64;
+
+void SetInsertionPoint(std::optional<ByteRange> candidate, int64_t c2pa_offset,
+                       std::optional<ByteRange>& result) {
+  if (!candidate.has_value()) {
+    // No candidate
+    return;
+  }
+
+  if (!result.has_value()) {
+    // No existing
+    result = candidate;
+    return;
+  }
+
+  if (c2pa_offset == -1) {
+    // No C2PA range found yet, use the larger range
+    if (result->length < candidate->length) {
+      // Existing is smaller and no C2PA range to account for
+      result = candidate;
+    }
+    return;
+  }
+
+  if (c2pa_offset >= candidate->offset &&
+      c2pa_offset <= candidate->offset + candidate->length) {
+    // C2PA is contained within the candidate, use it.
+    result = candidate;
+  }
+}
+
+absl::StatusOr<bool> CanOverwrite(const BmffBoxHeader& box,
+                                  riegeli::Reader& reader) {
+  if (box.type == "free") {
+    // Free boxes are always overwritable.
+    return true;
+  }
+  if (box.type != "uuid" || box.user_type != kC2paBmffBoxUuid) {
+    // The only other overwritable box is a C2PA uuid box.
+    return false;
+  }
+
+  std::string purpose;
+  if (!reader.Read(8, purpose)) {
+    return reader.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; purpose"));
+  }
+
+  if (absl::StartsWith(purpose, kBmffC2paBoxPurposeMerkle)) {
+    // Merkle boxes are not overwritable, we replace them with a free box.
+    return false;
+  }
+  if (absl::StartsWith(purpose, kBmffC2paBoxPurposeUpdate)) {
+    // Update boxes are not overwritable, we remove them.
+    return false;
+  }
+
+  // Original and Manifest boxes are overwritable.
+  return true;
+}
+
+absl::StatusOr<bool> IterateOverBmffBoxesInternal(XPath* parent,
+                                                  riegeli::Reader& reader,
+                                                  uint64_t end_offset,
+                                                  BmffBoxProcessor processor,
+                                                  int depth = 0) {
+  if (depth > kMaxBmffDepth) {
+    return absl::InvalidArgumentError("Too many nested BMFF boxes");
+  }
+  absl::flat_hash_map<std::string, int> count_by_type;
+  while (reader.pos() < (end_offset - kBmffMinSizeofBoxHeader)) {
+    BmffBoxHeader box;
+    if (auto box_or = ReadBmffBoxHeader(reader); box_or.ok()) {
+      box = std::move(box_or.value());
+    } else {
+      return box_or.status();
+    }
+
+    if (box.start > (std::numeric_limits<uint64_t>::max() - box.box_size)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Final box offset exceeds uint64_t::max"));
+    }
+    uint64_t box_end_offset = box.start + box.box_size;
+
+    // If the box extends beyond the end of the input, it's invalid.
+    if (box_end_offset > end_offset) {
+      if (parent == nullptr) {
+        return reader.StatusOrAnnotate(
+            absl::DataLossError("truncated BMFF box"));
+      }
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("sub-box extends beyond the parent"));
+    }
+
+    XPath xpath(parent, box.type);
+    xpath.SetPosition(++count_by_type[box.type]);
+    box.xpath = xpath.ToString();
+
+    // If it fails during processing, we can't continue.
+    bool continue_processing;
+    if (auto result = processor(box); result.ok()) {
+      continue_processing = *result;
+    } else {
+      return result.status();
+    }
+    if (!continue_processing) {
+      return false;
+    }
+
+    if (reader.pos() < box_end_offset && box.IsContainerBox()) {
+      if (auto sub_continue_or = IterateOverBmffBoxesInternal(
+              &xpath, reader, box_end_offset, processor, depth + 1);
+          sub_continue_or.ok()) {
+        continue_processing = *sub_continue_or;
+      } else {
+        return sub_continue_or.status();
+      }
+      if (!continue_processing) {
+        return false;
+      }
+    }
+
+    reader.Seek(box_end_offset);
+  }
+  return true;
+}
+
+}  // namespace
+
+bool BmffBoxHeader::IsContainerBox() const {
+  // This list mirrors the C2PA SDK's implementation:
+  // https://github.com/contentauth/c2pa-rs/blob/main/sdk/src/asset_handlers/bmff_io.rs#L982.
+  return type == "moov" || type == "trak" || type == "mdia" || type == "minf" ||
+         type == "stbl" || type == "moof" || type == "traf" || type == "edts" ||
+         type == "udta" || type == "dinf" || type == "tref" || type == "treg" ||
+         type == "mvex" || type == "mfra" || type == "meta" || type == "schi";
+}
+
+absl::StatusOr<BmffBoxHeader> ReadBmffBoxHeader(riegeli::Reader& input) {
+  BmffBoxHeader header;
+
+  header.start = input.pos();
+
+  uint32_t box_size32;
+  if (!riegeli::ReadBigEndian<uint32_t>(input, box_size32)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; box_size32"));
+  }
+  if (!input.Read(4, header.type)) {
+    return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; type"));
+  }
+  header.header_size = 8;
+
+  if (box_size32 == 1) {
+    if (!riegeli::ReadBigEndian<uint64_t>(input, header.box_size)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("kUnexpectedEof; box_size64"));
+    }
+    header.header_size = 16;
+  } else {
+    header.box_size = box_size32;
+  }
+
+  if (header.box_size < kBmffMinSizeofBoxHeader) {
+    // Unsupported box size.
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kInvalidData; unsupported box size"));
+  }
+
+  if (IsUuid(header.type)) {
+    if (!input.Read(kBmffUserTypeSize, header.user_type)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("kUnexpectedEof; user_type"));
+    }
+    header.header_size += kBmffUserTypeSize;
+  }
+  if ((IsBmffFullBox(header.type) ||
+       (IsUuid(header.type) && header.user_type == kC2paBmffBoxUuid)) &&
+      !IsQuickTimeMetaBox(header, input)) {
+    header.version_and_flags_size = 4;
+    if (!input.ReadByte(header.version)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("kUnexpectedEof; version"));
+    }
+    if (!input.Read(3, header.flags)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("kUnexpectedEof; flags"));
+    }
+  }
+  if (header.box_size < header.header_size + header.version_and_flags_size) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kInvalidData; box size too small for header"));
+  }
+  return header;
+}
+
+int64_t BmffBoxHeaderSize(const BmffBox& box) {
+  uint64_t header_size = kSizeOf32BitSize + kAtomTypeSize;
+  if (ShouldWriteUserType(box)) {
+    header_size += kBmffUserTypeSize;
+  }
+  if (ShouldWriteVersionFlags(box)) {
+    header_size += kVersionFlagsSize;
+  }
+  if (ShouldWriteSize64Bits(header_size + box.data_size)) {
+    header_size += kSizeOf64BitSize;
+  }
+  return header_size;
+}
+
+absl::StatusOr<int64_t> WriteBmffBoxHeader(const BmffBox& box,
+                                           riegeli::Writer& destination) {
+  bool success = true;
+
+  uint64_t header_size = BmffBoxHeaderSize(box);
+  uint64_t box_size = header_size + box.data_size;
+  bool should_write_size_64bits = ShouldWriteSize64Bits(box_size);
+  uint32_t box_size32 = should_write_size_64bits ? 1 : box_size;
+
+  success &= riegeli::WriteBigEndian<uint32_t>(box_size32, destination);
+  success &= box.type.size() == kAtomTypeSize && destination.Write(box.type);
+  if (should_write_size_64bits) {
+    success &= riegeli::WriteBigEndian<uint64_t>(box_size, destination);
+  }
+  if (ShouldWriteUserType(box)) {
+    success &= box.user_type.size() == kBmffUserTypeSize &&
+               destination.Write(box.user_type);
+  }
+  if (ShouldWriteVersionFlags(box)) {
+    success &= destination.WriteByte(box.version);
+    success &= box.flags.size() == (kVersionFlagsSize - 1) &&
+               destination.Write(box.flags);
+  }
+  if (!success) {
+    return absl::InternalError("failed to write box header");
+  }
+  return header_size;
+}
+
+absl::StatusOr<std::vector<BmffBoxHeader>> ReadBmffBoxHeaders(
+    riegeli::Reader& input) {
+  std::vector<BmffBoxHeader> result;
+  if (!input.Seek(0) || input.pos() != 0) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("failed to seek to start of input"));
+  }
+  auto result_status =
+      IterateOverBmffBoxes(input, [&result](const BmffBoxHeader& box) {
+        result.push_back(std::move(box));
+        return true;
+      });
+  if (result_status.ok()) {
+    return std::move(result);
+  }
+  return result_status;
+}
+
+absl::Status IterateOverBmffBoxes(riegeli::Reader& reader,
+                                  BmffBoxProcessor processor) {
+  if (!reader.SupportsSize() || !reader.Size().has_value()) {
+    return absl::InvalidArgumentError(
+        "manifest store not embedded: reader size cannot be determined");
+  }
+  if ((*reader.Size() - reader.pos()) == 0) {
+    return absl::OkStatus();
+  }
+
+  return IterateOverBmffBoxesInternal(nullptr, reader, *reader.Size(),
+                                      processor)
+      .status();
+}
+
+absl::StatusOr<ByteRange> LocateManifestInsertionPoint(
+    riegeli::Reader& reader) {
+  std::optional<ByteRange> result = std::nullopt;
+  std::optional<ByteRange> range = std::nullopt;
+
+  int64_t c2pa_offset = -1;
+  std::string last_atom_type_processed = "";
+  auto iterate_status = IterateOverBmffBoxes(
+      reader,
+      [&reader, &range, &c2pa_offset, &last_atom_type_processed,
+       &result](const BmffBoxHeader& box) -> absl::StatusOr<bool> {
+        ABSL_ASSIGN_OR_RETURN(bool overwritable, CanOverwrite(box, reader));
+
+        // We only care about the top level boxes, so skip all sub-boxes.
+        reader.Seek(box.start + box.box_size);
+        last_atom_type_processed = box.type;
+
+        if (!overwritable) {
+          SetInsertionPoint(range, c2pa_offset, result);
+          range = std::nullopt;
+
+          if (box.type == "mdat" || box.type == "moov") {
+            SetInsertionPoint(ByteRange{.offset = box.start, .length = 0},
+                              c2pa_offset, result);
+            return false;  // Terminate loop
+          }
+          return true;  // Continue
+        }
+
+        if (box.type == "uuid") {
+          c2pa_offset = box.start;
+        }
+
+        if (!range.has_value()) {
+          range = ByteRange{.offset = box.start, .length = 0};
+        }
+        range->length += box.box_size;
+
+        return true;  // Continue
+      });
+  ABSL_RETURN_IF_ERROR(iterate_status);
+  if (last_atom_type_processed != "mdat" &&
+      last_atom_type_processed != "moov") {
+    return absl::InvalidArgumentError("no mdat/moov box found");
+  }
+  SetInsertionPoint(range, c2pa_offset, result);
+  return result.value_or(ByteRange{.offset = 0, .length = 0});
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/box_header.h b/formats/bmff/box_header.h
new file mode 100644
index 0000000..8b77193
--- /dev/null
+++ b/formats/bmff/box_header.h
@@ -0,0 +1,181 @@
+// 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_FORMATS_BMFF_BOX_HEADER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_HEADER_H_
+
+#include <array>
+#include <cstdint>
+#include <cstring>
+#include <string>
+
+#include "absl/base/no_destructor.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/functional/function_ref.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/string_view.h"
+#include "formats/byte_range.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// A BMFF box header for reading.
+struct BmffBoxHeader {
+  uint64_t start;        // Absolute offset of the box header within input.
+  uint64_t box_size;     // Includes header and version/flags when present.
+  uint64_t header_size;  // Size of the header.
+  // Size of version and flags, if it is a full box (atom).
+  uint64_t version_and_flags_size = 0;
+  std::string type;            // The type of the box.
+  std::string xpath;           // The XPath of the box, like "/moov[1]/pssh".
+  std::string user_type = "";  // Only present in UUID.
+  // Version and flags only present in a full box (atom) or UUID.
+  // Check `version_and_flags_size > 0` to determine if these are present.
+  uint8_t version = 0;
+  std::string flags = "";
+
+  // Returns true if the box is at the root level (has no parent).
+  bool IsRootBox() const {
+    return xpath.empty() || !absl::StrContains(xpath.substr(1), "/");
+  }
+
+  // Returns true if the box is a Full Box (contains version and flags).
+  bool IsFullBox() const { return version_and_flags_size > 0; }
+
+  // Returns true if the box is a container box that can have sub-boxes.
+  bool IsContainerBox() const;
+};
+
+inline bool operator==(const BmffBoxHeader& a, const BmffBoxHeader& b) {
+  return a.start == b.start && a.box_size == b.box_size &&
+         a.header_size == b.header_size &&
+         a.version_and_flags_size == b.version_and_flags_size &&
+         a.type == b.type && a.xpath == b.xpath && a.user_type == b.user_type &&
+         a.version == b.version && a.flags == b.flags;
+}
+
+// A BMFF box header for writing.
+struct BmffBox {
+  std::string type;
+  std::string user_type;
+  uint8_t version = 0;
+  std::string flags;
+  uint64_t data_size = 0;
+};
+
+// Reads a BMFF box header from the input reader.
+// Returns the parsed header or an error status.
+absl::StatusOr<BmffBoxHeader> ReadBmffBoxHeader(riegeli::Reader& input);
+
+// Writes the header of the given BMFF box and returns the number of written
+// bytes.
+absl::StatusOr<int64_t> WriteBmffBoxHeader(const BmffBox& box,
+                                           riegeli::Writer& destination);
+
+// Returns the size of the encoded BMFF box header.
+int64_t BmffBoxHeaderSize(const BmffBox& box);
+
+// Returns true if the given box type is a Full Box.
+inline bool IsBmffFullBox(absl::string_view type);
+
+// The function signature for processing a BMFF box. The function is called for
+// each box in a BMFF file.
+using BmffBoxProcessor =
+    absl::FunctionRef<absl::StatusOr<bool>(const BmffBoxHeader&)>;
+
+// Iterates over all BMFF boxes in the given reader, including sub-boxes in a
+// depth-first traversal, calling the given `processor` function for each box.
+// The method will seek to position 0 in the reader before iterating.
+//
+// The `processor` function controls the iteration by returning an
+// `absl::StatusOr<bool>`:
+// - Return `true` to continue iterating over subsequent boxes.
+// - Return `false` to stop iterating early. `IterateOverBmffBoxes` will stop
+//   and return `absl::OkStatus()`.
+// - Return an error status to abort iteration immediately and propagate the
+//   error back to the caller.
+//
+// The processor can also affect iteration by seeking the reader:
+// - For container boxes, sub-box iteration will start reading from wherever
+//   the processor leaves the reader.
+// - To skip processing sub-boxes of a container box, the processor can seek
+//   the reader to the end of the current box (`box.start + box.box_size`).
+// - The iterator will always reset the reader position to the end of the
+//   current box before proceeding to the next sibling box.
+absl::Status IterateOverBmffBoxes(riegeli::Reader& reader,
+                                  BmffBoxProcessor processor);
+
+// Reads all BMFF box headers from the given input, including sub-boxes.
+absl::StatusOr<std::vector<BmffBoxHeader>> ReadBmffBoxHeaders(
+    riegeli::Reader& input);
+
+// Locates the insertion point for the C2PA manifest in the given reader.
+absl::StatusOr<ByteRange> LocateManifestInsertionPoint(riegeli::Reader& reader);
+
+namespace credentio_internal {
+
+// This list mirrors the C2PA SDK's implementation:
+// https://github.com/contentauth/c2pa-rs/blob/main/sdk/src/asset_handlers/bmff_io.rs#L58.
+constexpr std::array<std::string_view, 80> kFullBoxTypes = {
+    "pdin", "mvhd", "tkhd", "mdhd", "hdlr", "nmhd", "elng", "stsd", "stdp",
+    "stts", "ctts", "cslg", "stss", "stsh", "stdp", "elst", "dref", "stsz",
+    "stz2", "stsc", "stco", "co64", "padb", "subs", "saiz", "saio", "mehd",
+    "trex", "mfhd", "tfhd", "trun", "tfra", "mfro", "tfdt", "leva", "trep",
+    "assp", "sbgp", "sgpd", "csgp", "cprt", "tsel", "kind", "meta", "xml ",
+    "bxml", "iloc", "pitm", "ipro", "infe", "iinf", "iref", "ipma", "schm",
+    "fiin", "fpar", "fecr", "gitn", "fire", "stri", "stsg", "stvi", "csch",
+    "sidx", "ssix", "prft", "srpp", "vmhd", "smhd", "srat", "chnl", "dmix",
+    "txtC", "mime", "uri ", "uriI", "hmhd", "sthd", "vvhd", "medc"};
+
+constexpr bool AllTypesHaveSize4() {
+  for (const std::string_view type : kFullBoxTypes) {
+    if (type.size() != 4) {
+      return false;
+    }
+  }
+  return true;
+}
+
+// Requires that `type` is exactly 4 bytes long.
+inline uint32_t TypeAsUint32(absl::string_view type) {
+  uint32_t ret;
+  std::memcpy(&ret, type.data(), sizeof(ret));
+  return ret;
+}
+
+}  // namespace credentio_internal
+
+bool IsBmffFullBox(absl::string_view type) {
+  static_assert(
+      credentio_internal::AllTypesHaveSize4(),
+      "The following algorithm works only if all types are 4 bytes long.");
+  static const absl::NoDestructor<absl::flat_hash_set<uint32_t>>
+      kFullBoxTypesSet([]() {
+        absl::flat_hash_set<uint32_t> ret;
+        ret.reserve(credentio_internal::kFullBoxTypes.size());
+        for (const std::string_view type : credentio_internal::kFullBoxTypes) {
+          ret.insert(credentio_internal::TypeAsUint32(type));
+        }
+        return ret;
+      }());
+  return type.size() == 4 &&
+         kFullBoxTypesSet->contains(credentio_internal::TypeAsUint32(type));
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_HEADER_H_
diff --git a/formats/bmff/box_matcher.cc b/formats/bmff/box_matcher.cc
new file mode 100644
index 0000000..3bcddbe
--- /dev/null
+++ b/formats/bmff/box_matcher.cc
@@ -0,0 +1,115 @@
+// 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 "formats/bmff/box_matcher.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "formats/bmff/box_header.h"
+#include "formats/bmff/xpath.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+
+namespace credentio {
+namespace {
+
+uint32_t NetworkUInt24(std::string value) {
+  uint32_t result = 0;
+  for (int i = 0; i < 3; ++i) {
+    uint8_t byte = (i < value.size()) ? static_cast<uint8_t>(value[i]) : 0;
+    result = (result << 8) | byte;
+  }
+  return result;
+}
+
+bool MatchLength(const BmffRange& bmff_range, const BmffBoxHeader& header) {
+  if (!bmff_range.has_length()) {
+    return true;
+  }
+  return header.box_size == bmff_range.length();
+}
+
+bool MatchVersion(const BmffRange& bmff_range, const BmffBoxHeader& header) {
+  if (!bmff_range.has_version()) {
+    return true;
+  }
+  return header.version == bmff_range.version();
+}
+
+bool MatchFlags(const BmffRange& bmff_range, const BmffBoxHeader& header) {
+  if (!bmff_range.has_flags()) {
+    return true;
+  }
+  if (!header.IsFullBox()) {
+    return false;
+  }
+  if (!bmff_range.has_exact() || bmff_range.exact()) {
+    return header.flags == bmff_range.flags();
+  }
+  auto desired_flags = NetworkUInt24(std::string(bmff_range.flags()));
+  auto box_flags = NetworkUInt24(header.flags);
+  return (desired_flags & box_flags) == desired_flags;
+}
+
+}  // namespace
+
+bool BMFFBoxMatcher::Matches(BmffBoxHeader header) const {
+  if (!(XPathMatcher(bmff_range_.xpath()).Matches(header.xpath)) ||
+      !MatchLength(bmff_range_, header) || !MatchVersion(bmff_range_, header) ||
+      !MatchFlags(bmff_range_, header)) {
+    return false;
+  }
+  for (const auto& data : bmff_range_.data()) {
+    uint64_t offset = header.start + data.offset();
+    input_.Seek(offset);
+    if (input_.pos() != offset) {
+      return false;
+    }
+    std::string value;
+    if (!input_.Read(data.value().size(), value)) {
+      return false;
+    }
+    if (value != data.value()) {
+      return false;
+    }
+  }
+  return true;
+}
+
+std::vector<BMFFBoxMatcher::Range> BMFFBoxMatcher::MatchedRanges(
+    BmffBoxHeader header) const {
+  std::vector<Range> ranges;
+  if (bmff_range_.subsets_size() == 0) {
+    ranges.push_back({.offset = header.start,
+                      .length = static_cast<int64_t>(header.box_size)});
+    return ranges;
+  }
+  for (const auto& subset : bmff_range_.subsets()) {
+    int64_t offset = header.start + subset.offset();
+    int64_t end = header.start + subset.offset() + subset.length();
+    if (offset > end) {
+      // Invalid subset.
+      continue;
+    }
+    if (subset.length() == 0 || end > header.start + header.box_size) {
+      end = header.start + header.box_size;
+    }
+    ranges.push_back(
+        {.offset = header.start + subset.offset(), .length = end - offset});
+  }
+  return ranges;
+}
+}  // namespace credentio
diff --git a/formats/bmff/box_matcher.h b/formats/bmff/box_matcher.h
new file mode 100644
index 0000000..ef80281
--- /dev/null
+++ b/formats/bmff/box_matcher.h
@@ -0,0 +1,61 @@
+// 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_FORMATS_BMFF_BOX_MATCHER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_MATCHER_H_
+
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "formats/bmff/box_header.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// A matcher to match BMFF boxes against an exclusion in the
+// BmffBasedHashAssertion.
+class BMFFBoxMatcher {
+ public:
+  struct Range {
+    uint64_t offset;  // The offset within the file.
+    int64_t length;
+
+    bool operator==(const Range& other) const = default;
+  };
+  BMFFBoxMatcher(BmffRange bmff_range, riegeli::Reader* absl_nonnull input)
+      : bmff_range_(std::move(bmff_range)), input_(*ABSL_DIE_IF_NULL(input)) {}
+  // Returns true if the given Box matches the exclusion BmffRange in a
+  // BmffBasedHashAssertion.
+  bool Matches(BmffBoxHeader header) const;
+  // Returns a list of data ranges that should be excluded in the hash, which is
+  // empty if the box does not match the BmffRange. The caller is responsible
+  // for checking any out order or any overlap of the returned ranges.
+  // NOTE: callers must check `Matches` first.
+  std::vector<Range> MatchedRanges(BmffBoxHeader header) const;
+  // Returns true if this is for matching the entirety of the box, rather than
+  // some subsets of the box.
+  bool IsMatchingEntireBox() const { return bmff_range_.subsets_size() == 0; }
+
+ private:
+  BmffRange bmff_range_;
+  riegeli::Reader& input_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_BOX_MATCHER_H_
diff --git a/formats/bmff/box_matcher_test.cc b/formats/bmff/box_matcher_test.cc
new file mode 100644
index 0000000..de559e2
--- /dev/null
+++ b/formats/bmff/box_matcher_test.cc
@@ -0,0 +1,495 @@
+// 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 "formats/bmff/box_matcher.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/bmff/box_header.h"
+#include "formats/bmff/test_utils.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::BigBox;
+using ::credentio_testing::Box;
+using ::credentio_testing::BoxFlags;
+using ::credentio_testing::BoxVersion;
+using ::credentio_testing::UuidBoxPayload;
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+
+constexpr absl::string_view kFlagsForZero("\x00\x00\x00", 3);
+constexpr absl::string_view kFlagsForOne("\x00\x00\x01", 3);
+constexpr absl::string_view kFlagsForTwo("\x00\x00\x02", 3);
+
+std::vector<std::string> MatchBmffBoxes(
+    std::vector<credentio::BmffBoxHeader> boxes,
+    const credentio::BMFFBoxMatcher* matcher) {
+  std::vector<std::string> matched;
+  for (const auto& box : boxes) {
+    if (matcher->Matches(box)) {
+      matched.push_back(box.xpath);
+    }
+  }
+  return matched;
+}
+
+std::vector<credentio::BMFFBoxMatcher::Range> MatchBmffBoxRanges(
+    std::vector<credentio::BmffBoxHeader> boxes,
+    const credentio::BMFFBoxMatcher* matcher) {
+  std::vector<credentio::BMFFBoxMatcher::Range> matched;
+  for (const auto& box : boxes) {
+    if (!matcher->Matches(box)) {
+      continue;
+    }
+    auto ranges = matcher->MatchedRanges(box);
+    matched.insert(matched.end(), ranges.begin(), ranges.end());
+  }
+  return matched;
+}
+
+TEST(BoxMatcher, MatchXPath) {
+  std::string uuid = Box(
+      "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), ""));
+  std::string moov = Box("moov", "moov");
+  std::string trak_trak1_trak1 = Box("trak", "abcd");
+  std::string trak_trak1 = Box("trak", trak_trak1_trak1);
+  std::string trak_trak2 = BigBox("trak", "efgh");
+  std::string trak1 =
+      BigBox("trak", absl::StrCat(trak_trak1, moov, trak_trak2));
+  std::string trak2 = Box("trak", "ijkl");
+  std::string contents = absl::StrCat(uuid, trak1, trak2);
+  riegeli::StringReader<> input(contents);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak[1]/trak[1]/trak");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher),
+                ElementsAre("/trak[1]/trak[1]/trak[1]"));
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak[1]/trak/trak");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher),
+                ElementsAre("/trak[1]/trak[1]/trak[1]"));
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak/trak/trak");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher),
+                ElementsAre("/trak[1]/trak[1]/trak[1]"));
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak/trak/trak[1]");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher),
+                ElementsAre("/trak[1]/trak[1]/trak[1]"));
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak/trak/abcd");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher),
+                ElementsAre("/trak[1]", "/trak[2]"));
+  }
+}
+
+TEST(BoxMatcher, MatchLength) {
+  std::string uuid = Box(
+      "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), ""));
+  std::string moov = Box("moov", "moov");
+  std::string trak_trak1_trak1 = Box("trak", "abcd");
+  std::string trak_trak1 = Box("trak", trak_trak1_trak1);
+  std::string trak_trak2 = BigBox("trak", "efgh");
+  std::string trak1 =
+      BigBox("trak", absl::StrCat(trak_trak1, moov, trak_trak2));
+  std::string trak2 = Box("trak", "ijkl");
+  std::string contents = absl::StrCat(uuid, trak1, trak2);
+  riegeli::StringReader<> input(contents);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak");
+    range.set_length(68);
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/trak[1]"));
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak");
+    range.set_length(12);
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/trak[2]"));
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher),
+                ElementsAre("/trak[1]", "/trak[2]"));
+  }
+}
+
+TEST(BoxMatcher, MatchVersion) {
+  std::string uuid = Box(
+      "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(2), BoxFlags(0), ""));
+  riegeli::StringReader<> input(uuid);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/uuid");
+    range.set_version(2);
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/uuid");
+    range.set_version(1);
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/uuid");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+  }
+}
+
+TEST(BoxMatcher, MatchFlagsDefaultExactMatch) {
+  std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0),
+                                                kFlagsForOne, ""));
+  riegeli::StringReader<> input(uuid);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  credentio::BmffRange range;
+  range.set_xpath("/uuid");
+  range.set_flags(kFlagsForOne);
+  credentio::BMFFBoxMatcher matcher(range, &input);
+  EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+};
+
+TEST(BoxMatcher, MatchFlagsExplicitlyExactMatch) {
+  std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0),
+                                                kFlagsForOne, ""));
+  riegeli::StringReader<> input(uuid);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  credentio::BmffRange range;
+  range.set_xpath("/uuid");
+  range.set_flags(kFlagsForOne);
+  range.set_exact(true);
+  credentio::BMFFBoxMatcher matcher(range, &input);
+  EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+};
+
+TEST(BoxMatcher, MatchFlagsNotMatched) {
+  std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0),
+                                                kFlagsForOne, ""));
+  riegeli::StringReader<> input(uuid);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  credentio::BmffRange range;
+  range.set_xpath("/uuid");
+  range.set_flags(kFlagsForZero);
+  credentio::BMFFBoxMatcher matcher(range, &input);
+  EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+}
+
+TEST(BoxMatcher, MatchFlagsNotExactButMatched) {
+  credentio::BmffRange range;
+  range.set_xpath("/uuid");
+  range.set_flags(kFlagsForZero);
+  range.set_exact(false);
+  {
+    std::string uuid =
+        Box("uuid",
+            UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), kFlagsForOne, ""));
+    riegeli::StringReader<> input(uuid);
+    auto boxes_or = ReadBmffBoxHeaders(input);
+    ASSERT_TRUE(boxes_or.ok());
+    auto boxes = *boxes_or;
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+  }
+  {
+    std::string uuid =
+        Box("uuid",
+            UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), kFlagsForTwo, ""));
+    riegeli::StringReader<> input(uuid);
+    auto boxes_or = ReadBmffBoxHeaders(input);
+    ASSERT_TRUE(boxes_or.ok());
+    auto boxes = *boxes_or;
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+  }
+};
+
+TEST(BoxMatcher, MatchFlagsNotExactNotMatched) {
+  std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0),
+                                                kFlagsForOne, ""));
+  riegeli::StringReader<> input(uuid);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  credentio::BmffRange range;
+  range.set_xpath("/uuid");
+  range.set_flags(kFlagsForTwo);
+  range.set_exact(false);
+  credentio::BMFFBoxMatcher matcher(range, &input);
+  EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+};
+
+TEST(BoxMatcher, MatchFlagsNotFullBoxNotMatched) {
+  std::string some_box = Box("some", "abcd");
+  riegeli::StringReader<> input(some_box);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  credentio::BmffRange range;
+  range.set_xpath("/some");
+  range.set_flags(kFlagsForZero);
+  range.set_exact(false);
+  credentio::BMFFBoxMatcher matcher(range, &input);
+  EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+};
+
+TEST(BoxMatcher, NoFlagsMatched) {
+  {
+    std::string uuid =
+        Box("uuid",
+            UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), kFlagsForOne, ""));
+    riegeli::StringReader<> input(uuid);
+    auto boxes_or = ReadBmffBoxHeaders(input);
+    ASSERT_TRUE(boxes_or.ok());
+    auto boxes = *boxes_or;
+    credentio::BmffRange range;
+    range.set_xpath("/uuid");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+  };
+}
+
+TEST(BoxMatcher, MatchData) {
+  std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0),
+                                                BoxFlags(0), "abcd"));
+  riegeli::StringReader<> input(uuid);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/uuid");
+    auto data1 = range.add_data();
+    data1->set_offset(4);
+    data1->set_value("uuid");
+    auto data2 = range.add_data();
+    data2->set_offset(28);
+    data2->set_value("abcd");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/uuid");
+    auto data1 = range.add_data();
+    data1->set_offset(4);
+    data1->set_value("aaaa");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/uuid");
+    auto data1 = range.add_data();
+    data1->set_offset(10000);
+    data1->set_value("uuid");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+}
+
+TEST(BoxMatcher, MismatchOneField) {
+  std::string uuid = Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(1),
+                                                kFlagsForOne, "abcd"));
+  riegeli::StringReader<> input(uuid);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+
+  credentio::BmffRange matched_range;
+  matched_range.set_xpath("/uuid");
+  matched_range.set_length(32);
+  matched_range.set_version(1);
+  matched_range.set_flags(kFlagsForOne);
+  auto data1 = matched_range.add_data();
+  data1->set_offset(4);
+  data1->set_value("uuid");
+  auto data2 = matched_range.add_data();
+  data2->set_offset(28);
+  data2->set_value("abcd");
+
+  credentio::BMFFBoxMatcher matcher(matched_range, &input);
+  ASSERT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+
+  {
+    credentio::BmffRange range = matched_range;
+    range.set_xpath("/uuid[1]");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), ElementsAre("/uuid[1]"));
+  }
+  {
+    credentio::BmffRange range = matched_range;
+    range.set_length(31);  // length not matched.
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+  {
+    credentio::BmffRange range = matched_range;
+    range.set_version(2);  // version not matched.
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+  {
+    credentio::BmffRange range = matched_range;
+    range.set_length(1);  // length not matched.
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+  {
+    credentio::BmffRange range = matched_range;
+    range.set_flags(kFlagsForTwo);  // flags not matched.
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+  {
+    credentio::BmffRange range = matched_range;
+    range.mutable_data(1)->set_value("abce");  // data not matched.
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxes(boxes, &matcher), IsEmpty());
+  }
+}
+
+TEST(BoxMatcher, MatchedRanges) {
+  std::string uuid = Box(
+      "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), ""));
+  std::string moov = Box("moov", "moov");      // box_size = 12
+  std::string trak1 = Box("trak", moov);       // box_size = 20
+  std::string trak2 = BigBox("trak", "ijkl");  // box_size = 20
+  std::string contents = absl::StrCat(uuid, trak1, trak2);
+  riegeli::StringReader<> input(contents);
+  auto boxes_or = ReadBmffBoxHeaders(input);
+  ASSERT_TRUE(boxes_or.ok());
+  auto boxes = *boxes_or;
+  // Single box matched.
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak[1]");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxRanges(boxes, &matcher),
+                ElementsAre(credentio::BMFFBoxMatcher::Range{.offset = 28,
+                                                             .length = 20}));
+  }
+  // Multiple boxes matched.
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak");
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(
+        MatchBmffBoxRanges(boxes, &matcher),
+        ElementsAre(
+            credentio::BMFFBoxMatcher::Range{.offset = 28, .length = 20},
+            credentio::BMFFBoxMatcher::Range{.offset = 48, .length = 20}));
+  }
+  // Return subsets of the box.
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak[1]");
+    auto subset = range.add_subsets();
+    subset->set_offset(12);
+    subset->set_length(1);
+    subset = range.add_subsets();
+    subset->set_offset(16);
+    subset->set_length(2);
+    subset = range.add_subsets();
+    subset->set_offset(18);
+    subset->set_length(0);
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(
+        MatchBmffBoxRanges(boxes, &matcher),
+        ElementsAre(
+            credentio::BMFFBoxMatcher::Range{.offset = 40, .length = 1},
+            credentio::BMFFBoxMatcher::Range{.offset = 44, .length = 2},
+            credentio::BMFFBoxMatcher::Range{.offset = 46, .length = 2}));
+  }
+  // The last entry may have a length of zero; this indicates that the remainder
+  // of the box from that relative byte offset onward is excluded.
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak[1]");
+    auto subset = range.add_subsets();
+    subset->set_offset(18);
+    subset->set_length(0);
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxRanges(boxes, &matcher),
+                ElementsAre(credentio::BMFFBoxMatcher::Range{.offset = 46,
+                                                             .length = 2}));
+  }
+  //  Relative byte offset plus length that exceeds the length of the box is
+  //  allowed; bytes beyond the end of the box are never hashed.
+  {
+    credentio::BmffRange range;
+    range.set_xpath("/trak[1]");
+    auto subset = range.add_subsets();
+    subset->set_offset(18);
+    subset->set_length(100);
+    credentio::BMFFBoxMatcher matcher(range, &input);
+    EXPECT_THAT(MatchBmffBoxRanges(boxes, &matcher),
+                ElementsAre(credentio::BMFFBoxMatcher::Range{.offset = 46,
+                                                             .length = 2}));
+  }
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/bmff/constants.h b/formats/bmff/constants.h
new file mode 100644
index 0000000..8ad9b7f
--- /dev/null
+++ b/formats/bmff/constants.h
@@ -0,0 +1,40 @@
+// 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_FORMATS_BMFF_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_CONSTANTS_H_
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+inline constexpr absl::string_view kBmffC2paBoxPurposeManifest = "manifest";
+inline constexpr absl::string_view kBmffC2paBoxPurposeOriginal = "original";
+inline constexpr absl::string_view kBmffC2paBoxPurposeMerkle = "merkle";
+inline constexpr absl::string_view kBmffC2paBoxPurposeUpdate = "update";
+
+// The minimum size of a BMFF box header is 8 bytes, consisting of a 4-byte
+// size and a 4-byte type.
+constexpr int kBmffMinSizeofBoxHeader = 8;
+
+// The size of the user type in a UUID box.
+constexpr int kBmffUserTypeSize = 16;
+
+inline constexpr absl::string_view kBmffComponentSubtypeVideo = "vide";
+inline constexpr absl::string_view kBmffComponentSubtypeSound = "soun";
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_CONSTANTS_H_
diff --git a/formats/bmff/extractor.cc b/formats/bmff/extractor.cc
new file mode 100644
index 0000000..bbe8c73
--- /dev/null
+++ b/formats/bmff/extractor.cc
@@ -0,0 +1,185 @@
+// 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 "formats/bmff/extractor.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "constants/labels.h"
+#include "formats/bmff/box_header.h"
+#include "formats/byte_range.h"
+#include "jumbf/utils.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/riegeli.h"
+
+namespace credentio {
+namespace {
+
+constexpr absl::string_view kZeroFlags("\x00\x00\x00", 3);
+constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;  // 10 MiB
+
+absl::StatusOr<std::string> ReadPurpose(riegeli::Reader& contents) {
+  std::string purpose;
+  if (!ReadNullTerminatedString(contents, 20, purpose)) {
+    return contents.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof"));
+  }
+  return purpose;
+}
+
+struct Result {
+  ByteRange location;
+  std::string manifest;
+};
+
+absl::StatusOr<Result> ExtractManifest(riegeli::Reader& input,
+                                       int64_t end_offset) {
+  Result result{
+      .location{.offset = 0, .length = 0},
+      .manifest = "",
+  };
+
+  if (!input.SupportsSize() || !input.Size().has_value()) {
+    return absl::InvalidArgumentError(
+        "manifest store not embedded: reader size cannot be determined");
+  }
+
+  uint64_t eof_offset =
+      end_offset < 0 ? *input.Size() : static_cast<uint64_t>(end_offset);
+
+  absl::Status iteration_status = IterateOverBmffBoxes(
+      input,
+      [&input, &eof_offset,
+       &result](const BmffBoxHeader& header) -> absl::StatusOr<bool> {
+        // Validate that the end of the current box is within the end offset.
+        if (header.start + header.box_size > eof_offset) {
+          return absl::DataLossError("kUnexpectedEof; truncated BMFF box");
+        }
+        uint64_t box_end = header.start + header.box_size;
+
+        if (header.type == "mdat" || header.type == "moov") {
+          return false;  // Terminate loop
+        }
+
+        if (header.type != "uuid" || header.user_type != kC2paBmffBoxUuid) {
+          // Skip irrelevant box.
+          if (!input.Seek(box_end)) {
+            return input.StatusOrAnnotate(
+                absl::InternalError("failed to seek to the end of the atom"));
+          }
+          return true;  // Continue
+        }
+
+        // C2PA box
+        if (!result.manifest.empty()) {
+          return absl::NotFoundError("Multiple manifest stores found");
+        }
+        if (header.version != 0) {
+          return absl::InvalidArgumentError(absl::Substitute(
+              "unsupported C2PA box version ($0)", header.version));
+        }
+        if (header.flags != kZeroFlags) {
+          return absl::InvalidArgumentError(
+              absl::Substitute("unsupported C2PA box flags ($0)",
+                               absl::BytesToHexString(header.flags)));
+        }
+
+        ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(input));
+        if (purpose != "manifest") {
+          // Ignore non-manifest C2PA boxes.
+          if (!input.Seek(box_end)) {
+            return input.StatusOrAnnotate(
+                absl::InternalError("failed to seek to the end of the atom"));
+          }
+          return true;  // Continue
+        }
+
+        // Skip the merkle offset.
+        if (!input.Skip(sizeof(uint64_t))) {
+          return input.StatusOrAnnotate(
+              absl::DataLossError("kUnexpectedEof; merkle offset"));
+        }
+
+        if (box_end < input.pos()) {
+          return input.StatusOrAnnotate(
+              absl::DataLossError("kUnexpectedEof; payload"));
+        }
+
+        uint64_t payload_size = box_end - input.pos();
+        if (payload_size > kMaxPayloadSize) {
+          return absl::InvalidArgumentError(absl::Substitute(
+              "BMFF C2PA box is too large to extract ($0 > $1)", payload_size,
+              kMaxPayloadSize));
+        }
+        result.location = {.offset = header.start,
+                           .length = box_end - header.start};
+        if (!input.Read(payload_size, result.manifest)) {
+          return input.StatusOrAnnotate(
+              absl::InternalError("failed to read payload"));
+        }
+
+        if (!input.Seek(box_end)) {
+          return input.StatusOrAnnotate(
+              absl::InternalError("failed to seek to the end of the atom"));
+        }
+        return true;  // Continue
+      });
+  if (!iteration_status.ok()) {
+    return absl::NotFoundError(
+        absl::StrCat("No manifest store found; ", iteration_status.message()));
+  }
+  if (result.manifest.empty()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return result;
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> BmffExtractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  ABSL_ASSIGN_OR_RETURN(auto result, ExtractManifest(input, /*end_offset=*/-1));
+  return result.manifest;
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+BmffExtractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                            ExtractOptions options) const {
+  auto result = ExtractManifest(input, options.end_offset);
+  if (!result.ok()) {
+    if (options.requires_c2pa) {
+      return result.status();
+    }
+    return std::nullopt;
+  }
+  return (*result).location;
+}
+
+bool BmffExtractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/extractor.h b/formats/bmff/extractor.h
new file mode 100644
index 0000000..9b591e4
--- /dev/null
+++ b/formats/bmff/extractor.h
@@ -0,0 +1,55 @@
+// 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_FORMATS_BMFF_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_byte_info.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Extracts C2PA Manifest Store from an ISO BMFF based file (e.g., mp4).
+class BmffExtractor : public FormatExtractor {
+ public:
+  BmffExtractor() = default;
+  ~BmffExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  // BMFF does not have a concept of boxes, so this always returns an error.
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError("BMFF does not have a concept of boxes");
+  }
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EXTRACTOR_H_
diff --git a/formats/bmff/extractor_test.cc b/formats/bmff/extractor_test.cc
new file mode 100644
index 0000000..d3dc19a
--- /dev/null
+++ b/formats/bmff/extractor_test.cc
@@ -0,0 +1,299 @@
+// 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 "formats/bmff/extractor.h"
+
+#include <cstdint>
+#include <limits>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/asset_box.h"
+#include "formats/asset_byte_info.h"
+#include "formats/bmff/test_utils.h"
+#include "formats/byte_range.h"
+#include "formats/extractor_result.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/jumbf_utils.h"
+#include "testing/test_string_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::BigBox;
+using ::credentio_testing::Box;
+using ::credentio_testing::BoxFlags;
+using ::credentio_testing::BoxVersion;
+using ::credentio_testing::C2paBoxPayload;
+using ::credentio_testing::ManifestBigBox;
+using ::credentio_testing::ManifestBox;
+using ::credentio_testing::Uint32Str;
+using ::credentio_testing::Uint64Str;
+using ::credentio_testing::UuidBoxPayload;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+
+constexpr int64_t kMaxChunkBytes = 10 * 1024 * 1024;  // 2 MiB
+
+struct TestCase {
+  std::string name;
+  std::string contents;
+  absl::StatusOr<ExtractorResult> result;
+};
+
+class ExtractorTest : public testing::TestWithParam<TestCase> {};
+
+INSTANTIATE_TEST_SUITE_P(
+    ExtractorTests, ExtractorTest,
+    testing::ValuesIn({
+        TestCase{
+            .name = "EmptyContent",
+            .contents = "",
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "C2paBox",
+            .contents = absl::StrCat(Box("blah", "irrelevant content"),
+                                     ManifestBox(/*merkle_offset=*/0, "foo")),
+            .result = ExtractorResult{.manifest_store = "foo",
+                                      .asset_byte_info =
+                                          {.manifest_store_location =
+                                               ByteRange{.offset = 26,
+                                                         .length = 48}}},
+        },
+        TestCase{
+            .name = "BigC2paBox",
+            .contents = absl::StrCat(Box("blah", "irrelevant content"),
+                                     ManifestBigBox(/*merkle_offset=*/0,
+                                                    "foo")),
+            .result = ExtractorResult{.manifest_store = "foo",
+                                      .asset_byte_info =
+                                          {.manifest_store_location =
+                                               ByteRange{.offset = 26,
+                                                         .length = 56}}},
+        },
+        TestCase{
+            .name = "C2paBoxTooLarge",
+            .contents = absl::StrCat(Box("blah", "irrelevant content"),
+                                     ManifestBox(/*merkle_offset=*/0,
+                                                 std::string(kMaxChunkBytes + 1,
+                                                             'Z'))),
+            .result = absl::NotFoundError("C2PA box is too large"),
+        },
+        TestCase{
+            .name = "NoC2paBox",
+            .contents = absl::StrCat(Box("blah", "irrelevant content"),
+                                     Box("asdf", "more irrelevant content"),
+                                     BigBox("zxcv", "whatever")),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "C2paBoxAfterMoovIgnored",
+            .contents = absl::StrCat(Box("blah", "irrelevant content"),
+                                     Box("moov", "also irrelevant"),
+                                     Box("uuid", absl::StrCat(kC2paBmffBoxUuid,
+                                                              "foo"))),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "C2paBoxAfterMdatIgnored",
+            .contents = absl::StrCat(Box("blah", "irrelevant content"),
+                                     Box("mdat", "also irrelevant"),
+                                     Box("uuid", absl::StrCat(kC2paBmffBoxUuid,
+                                                              "foo"))),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            // Quoting
+            // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedded_2:
+            // "If there are multiple C2PA Manifest Stores present in an asset,
+            // they shall all be considered as invalid and the validation should
+            // treat this as if no manifests were located."
+            .name = "MultipleC2paBoxesIgnored",
+            .contents = absl::StrCat(ManifestBox(/*merkle_offset=*/0, "foo"),
+                                     ManifestBox(/*merkle_offset=*/0, "bar")),
+            .result = absl::NotFoundError("Multiple manifest stores found"),
+        },
+        TestCase{
+            .name = "NonC2paUuidBoxIgnored",
+            .contents = absl::StrCat(Box("uuid",
+                                         "abcdefghijklmnop not a C2PA box"),
+                                     ManifestBox(/*merkle_offset=*/0, "foo")),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "foo",
+                    .asset_byte_info = {.manifest_store_location =
+                                            ByteRange{
+                                                .offset = 39, .length = 48}}},
+        },
+        TestCase{
+            .name = "TruncatedBoxHeader",
+            .contents = "abcdefg",
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "TruncatedBoxPayload",
+            .contents =
+                []() {
+                  std::string contents = Box("asdf", "payload");
+                  contents.resize(contents.size() - 1);
+                  return contents;
+                }(),
+            .result = absl::NotFoundError("truncated BMFF box"),
+        },
+        TestCase{
+            .name = "TruncatedBoxPayloadHugeSize",
+            .contents = absl::StrCat(
+                Uint32Str(1), "asdf",
+                Uint64Str(std::numeric_limits<int64_t>::max()), "payload"),
+            .result = absl::NotFoundError("truncated BMFF box"),
+        },
+        TestCase{
+            .name = "ShortUuidBox",
+            .contents = Box("uuid", "foo"),
+            .result = absl::NotFoundError("kUnexpectedEof"),
+        },
+        TestCase{
+            .name = "InvalidBoxSize",
+            .contents = absl::StrCat(Uint32Str(2), "asdf", "payload"),
+            .result = absl::NotFoundError("kInvalidData"),
+        },
+        TestCase{
+            .name = "InvalidBigBoxSize",
+            .contents = absl::StrCat(Uint32Str(1), "asdf", Uint64Str(15),
+                                     "payload"),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "InvalidVersion",
+            .contents = Box("uuid",
+                            UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(1),
+                                           BoxFlags(0), "")),
+            .result = absl::NotFoundError("unsupported C2PA box version"),
+        },
+        TestCase{
+            .name = "InvalidFlags",
+            .contents = Box("uuid",
+                            UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0),
+                                           BoxFlags(1), "")),
+            .result = absl::NotFoundError("unsupported C2PA box flags"),
+        },
+        TestCase{
+            .name = "NonManifestC2paBoxIgnored",
+            .contents = absl::StrCat(
+                Box("uuid", C2paBoxPayload("merkle", "ignored data")),
+                ManifestBox(/*merkle_offset=*/0, "foo")),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "foo",
+                    .asset_byte_info = {.manifest_store_location =
+                                            ByteRange{
+                                                .offset = 47, .length = 48}}},
+        },
+        TestCase{
+            // Not enough bytes for the merkle offset.
+            .name = "TruncatedManifestBox",
+            .contents = Box("uuid", C2paBoxPayload("manifest", "asdfjkl")),
+            .result = absl::NotFoundError("kUnexpectedEof"),
+        },
+    }),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+TEST_P(ExtractorTest, ExtractManifestStore) {
+  riegeli::StringReader<> input(GetParam().contents);
+  absl::StatusOr<std::string> result =
+      BmffExtractor().ExtractManifestStore(input);
+
+  absl::StatusOr<ExtractorResult> expected_result = GetParam().result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_THAT(*result, Eq(expected_result->manifest_store));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) {
+  std::string contents = "padding" + GetParam().contents;
+  riegeli::StringReader<> input(contents);
+
+  ASSERT_TRUE(input.Seek(7));
+  absl::StatusOr<std::string> result =
+      BmffExtractor().ExtractManifestStore(input);
+
+  absl::StatusOr<ExtractorResult> expected_result = GetParam().result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_THAT(*result, Eq(expected_result->manifest_store));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocation) {
+  riegeli::StringReader<> input(GetParam().contents);
+  absl::StatusOr<std::optional<ByteRange>> result =
+      BmffExtractor().ExtractManifestStoreLocation(input,
+                                                   /*options=*/{});
+
+  absl::StatusOr<ExtractorResult> expected_result = GetParam().result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_THAT(*result,
+                Eq(expected_result->asset_byte_info.manifest_store_location));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxes) {
+  riegeli::StringReader<> input(GetParam().contents);
+  absl::StatusOr<std::vector<AssetBox>> result =
+      BmffExtractor().ExtractBoxes(input,
+                                   /*options=*/{});
+
+  // BMFF does not have a concept of boxes, calling this method should always
+  // fail.
+  EXPECT_THAT(result, StatusIs(absl::StatusCode::kUnimplemented,
+                               "BMFF does not have a concept of boxes"));
+}
+
+TEST(BmffExtractorTest, IsManifestStore) {
+  EXPECT_TRUE(BmffExtractor().MightBeC2paManifestStore(
+      CreateStartOfManifestStorePayload()));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/bmff/format.cc b/formats/bmff/format.cc
new file mode 100644
index 0000000..2e54fd1
--- /dev/null
+++ b/formats/bmff/format.cc
@@ -0,0 +1,36 @@
+// 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 "formats/bmff/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/bmff/assessor.h"
+#include "formats/bmff/extractor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateBmffFormat() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<BmffAssessor>(),
+      .extractor = std::make_unique<BmffExtractor>(),
+      .mime_types = {"video/mp4", "video/quicktime", "image/heic", "image/heif",
+                     "image/avif", "audio/mp4"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/format.h b/formats/bmff/format.h
new file mode 100644
index 0000000..28a9432
--- /dev/null
+++ b/formats/bmff/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_BMFF_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateBmffFormat();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_FORMAT_H_
diff --git a/formats/bmff/test_utils.h b/formats/bmff/test_utils.h
new file mode 100644
index 0000000..2410736
--- /dev/null
+++ b/formats/bmff/test_utils.h
@@ -0,0 +1,107 @@
+// 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_FORMATS_BMFF_TEST_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_TEST_UTILS_H_
+
+#include <cstdint>
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "testing/test_string_utils.h"
+
+namespace credentio_testing {
+
+using ::credentio::kC2paBmffBoxUuid;
+
+inline constexpr absl::string_view kNul("\x00", 1);
+
+inline std::string Box(absl::string_view type, absl::string_view data) {
+  CHECK_EQ(type.size(), 4);
+  return absl::StrCat(Uint32Str(data.size() + 8), type, data);
+}
+
+inline std::string BadBox(absl::string_view type, absl::string_view data,
+                          uint32_t data_size) {
+  CHECK_EQ(type.size(), 4);
+  return absl::StrCat(Uint32Str(data_size), type, data);
+}
+
+inline std::string BigBox(absl::string_view type, absl::string_view data) {
+  CHECK_EQ(type.size(), 4);
+  return absl::StrCat(Uint32Str(1), type, Uint64Str(data.size() + 16), data);
+}
+
+inline std::string BoxVersion(uint8_t version) { return Uint8Str(version); }
+
+inline std::string BoxFlags(uint32_t flags) {
+  CHECK_EQ(flags & 0xff000000, 0);
+  return Uint32Str(flags).substr(1);
+}
+
+// Returns the data payload of a UUID box, suitable for passing to Box() or
+// BigBox().
+inline std::string UuidBoxPayload(absl::string_view uuid,
+                                  absl::string_view version,
+                                  absl::string_view flags,
+                                  absl::string_view data) {
+  CHECK_EQ(uuid.size(), 16);
+  CHECK_EQ(version.size(), 1);
+  CHECK_EQ(flags.size(), 3);
+  return absl::StrCat(uuid, version, flags, data);
+}
+
+// Returns the data payload of a C2PA manifest box, suitable for passing to
+// Box() or BigBox().
+inline std::string C2paBoxPayload(absl::string_view purpose,
+                                  absl::string_view data) {
+  return UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0),
+                        absl::StrCat(purpose, kNul, data));
+}
+
+// Returns the data payload of a well-formed C2PA manifest box (i.e., starting
+// with an absolute offset to the first `merkle` C2PA box), suitable for passing
+// to Box() or BigBox().
+inline std::string ManifestBoxPayload(uint64_t merkle_offset,
+                                      absl::string_view data) {
+  return C2paBoxPayload("manifest",
+                        absl::StrCat(Uint64Str(merkle_offset), data));
+}
+
+// Returns a full C2PA manifest box encoded as a standard (small) Box.
+inline std::string ManifestBox(uint64_t merkle_offset, absl::string_view data) {
+  return Box("uuid", ManifestBoxPayload(merkle_offset, data));
+}
+
+// Returns a full C2PA manifest box encoded as a BigBox.
+inline std::string ManifestBigBox(uint64_t merkle_offset,
+                                  absl::string_view data) {
+  return BigBox("uuid", ManifestBoxPayload(merkle_offset, data));
+}
+
+inline std::string C2paUuidBox(absl::string_view data,
+                               absl::string_view box_purpose = "manifest",
+                               uint64_t merkle_offset = 0) {
+  return Box("uuid",
+             C2paBoxPayload(box_purpose,
+                            absl::StrCat(Uint64Str(merkle_offset), data)));
+}
+
+}  // namespace credentio_testing
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_TEST_UTILS_H_
diff --git a/formats/bmff/xpath.cc b/formats/bmff/xpath.cc
new file mode 100644
index 0000000..95d6735
--- /dev/null
+++ b/formats/bmff/xpath.cc
@@ -0,0 +1,85 @@
+// 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 "formats/bmff/xpath.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_split.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/strip.h"
+
+namespace credentio {
+namespace {
+// Returns true if the given path matches the pattern. Like,
+// "/a[1]" matches "/a[1]";
+// "/a[1]" also matches "/a";
+// but "/a[1]" doesn't match "/a[2]".
+// Similarly, "/a[1]/b[2]/c[3]" matches "/a/b/c", "/a[1]/b/c", "/a/b[2]/c",
+// "/a/b/c[3]", etc;
+// but "/a[2]/b[2]/c[3]" doesn't match "/a[1]/b/c".
+// Note that while patterns like "/a/b/c" are expected, we don't expect to see
+// paths like "/a/b/c" because positional indexes should always be applied in
+// the path.
+bool MatchXPath(absl::string_view path, absl::string_view pattern) {
+  if (path == pattern) {
+    return true;
+  }
+  std::vector<absl::string_view> terms_a = absl::StrSplit(path, '/');
+  std::vector<absl::string_view> terms_b = absl::StrSplit(pattern, '/');
+  if (terms_a.size() != terms_b.size()) {
+    return false;
+  }
+  for (int i = 0; i < terms_a.size(); ++i) {
+    if (terms_a[i] == terms_b[i]) {
+      continue;
+    }
+    if (absl::StrContains(terms_b[i], "[")) {
+      // Term b is a positioned element; requires exact match.
+      return false;
+    }
+    absl::string_view remainder = terms_a[i];
+    if (absl::ConsumePrefix(&remainder, terms_b[i]) &&
+        absl::StartsWith(remainder, "[")) {
+      continue;
+    }
+    return false;
+  }
+  return true;
+};
+}  // namespace
+
+bool XPathMatcher::Matches(absl::string_view path) const {
+  return MatchXPath(path, pattern_);
+}
+
+std::string XPath::ToString() const {
+  std::string result;
+  for (const XPath* cursor = this; cursor != nullptr;
+       cursor = cursor->parent_) {
+    std::string element_name =
+        absl::StrCat("/", cursor->name_,
+                     cursor->position_.has_value()
+                         ? absl::StrCat("[", *cursor->position_, "]")
+                         : "");
+    result = absl::StrCat(element_name, result);
+  }
+  return result;
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/xpath.h b/formats/bmff/xpath.h
new file mode 100644
index 0000000..3882ebc
--- /dev/null
+++ b/formats/bmff/xpath.h
@@ -0,0 +1,64 @@
+// 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_FORMATS_BMFF_XPATH_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_XPATH_H_
+
+#include <optional>
+#include <string>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// An XPath represents a path from the root to a BMFF box, with the formatted
+// string according to https://www.w3.org/TR/xpath-10/.
+class XPath {
+ public:
+  // Constructs an XPath with a parent and a name.
+  XPath(const XPath* parent, std::string name) : parent_(parent), name_(name) {}
+
+  // Constructs an XPath with a parent, a name, and a position.
+  XPath(const XPath* parent, std::string name, int position)
+      : parent_(parent), name_(name), position_(position) {}
+
+  // Sets the position of the element for this path.
+  void SetPosition(int position) { position_ = position; }
+  // Returns the string representation of this full path.
+  std::string ToString() const;
+
+ private:
+  // The parent of this path.
+  const XPath* parent_;
+  // The name of the element pointed by this path.
+  std::string name_;
+  // The position of the element for this path. The position represents that
+  // this path is the Nth child with tag name in its parent.
+  std::optional<int> position_ = std::nullopt;
+};
+
+// A util class to match a XPath.
+class XPathMatcher {
+ public:
+  explicit XPathMatcher(absl::string_view pattern) : pattern_(pattern) {}
+  bool Matches(absl::string_view path) const;
+
+ private:
+  std::string pattern_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_XPATH_H_
diff --git a/formats/bmff/xpath_test.cc b/formats/bmff/xpath_test.cc
new file mode 100644
index 0000000..eda3852
--- /dev/null
+++ b/formats/bmff/xpath_test.cc
@@ -0,0 +1,52 @@
+// 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 "formats/bmff/xpath.h"
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::testing::Eq;
+
+class XPathTest : public ::testing::Test {
+ protected:
+  XPath a1_ = XPath(nullptr, "A", 1);
+  XPath a1_b1_ = XPath(&a1_, "B", 1);
+  XPath a1_b1_c1_ = XPath(&a1_b1_, "C", 1);
+  XPath a1_b1_c2_ = XPath(&a1_b1_, "C", 2);
+  XPath a_ = XPath(nullptr, "A");
+  XPath a_b1_ = XPath(&a_, "B", 1);
+};
+
+TEST_F(XPathTest, ToString) {
+  EXPECT_THAT(a1_.ToString(), Eq("/A[1]"));
+  EXPECT_THAT(a1_b1_.ToString(), Eq("/A[1]/B[1]"));
+  EXPECT_THAT(a1_b1_c1_.ToString(), Eq("/A[1]/B[1]/C[1]"));
+  EXPECT_THAT(a1_b1_c2_.ToString(), Eq("/A[1]/B[1]/C[2]"));
+  EXPECT_THAT(a_.ToString(), Eq("/A"));
+  EXPECT_THAT(a_b1_.ToString(), Eq("/A/B[1]"));
+}
+
+TEST_F(XPathTest, XPathMatcherMatches) {
+  XPathMatcher matcher("/moov/meta");
+  EXPECT_TRUE(matcher.Matches("/moov[1]/meta[1]"));
+  EXPECT_FALSE(matcher.Matches("/[moo[1]/[met[1]"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/byte_range.h b/formats/byte_range.h
new file mode 100644
index 0000000..2ab0b58
--- /dev/null
+++ b/formats/byte_range.h
@@ -0,0 +1,35 @@
+// 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_FORMATS_BYTE_RANGE_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BYTE_RANGE_H_
+
+#include <cstdint>
+
+namespace credentio {
+
+// A range of bytes in an asset.
+struct ByteRange {
+  uint64_t offset;
+  uint64_t length;
+
+  bool operator==(const ByteRange& other) const {
+    return offset == other.offset && length == other.length;
+  }
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BYTE_RANGE_H_
diff --git a/formats/core_registry.cc b/formats/core_registry.cc
new file mode 100644
index 0000000..1a60c09
--- /dev/null
+++ b/formats/core_registry.cc
@@ -0,0 +1,63 @@
+// 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 "formats/core_registry.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/log/log.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/bmff/format.h"
+#include "formats/format.h"
+#include "formats/gif/format.h"
+#include "formats/id3/format.h"
+#include "formats/jpeg/format.h"
+#include "formats/pdf/format.h"
+#include "formats/png/format.h"
+#include "formats/registry.h"
+#include "formats/riff/format.h"
+#include "formats/tiff/format.h"
+#include "formats/zip/format.h"
+
+namespace credentio {
+
+std::unique_ptr<FormatRegistry> CreateCoreFormatRegistry() {
+  auto registry = std::make_unique<FormatRegistry>();
+
+  auto register_format = [&](absl::StatusOr<std::unique_ptr<Format>> format,
+                             absl::string_view name) {
+    if (format.ok()) {
+      registry->Register(std::move(*format));
+    } else {
+      LOG(WARNING) << "Failed to create and register format: " << name;
+    }
+  };
+
+  register_format(CreateJpegFormat(), "jpeg");
+  register_format(CreatePngFormat(), "png");
+  register_format(CreateBmffFormat(), "bmff");
+  register_format(CreateTiffFormat(), "tiff");
+  register_format(CreateRiffFormat(), "riff");
+  register_format(CreateGifFormat(), "gif");
+  register_format(CreatePdfFormat(), "pdf");
+  register_format(CreateId3Format(), "id3");
+  register_format(CreateZipFormat(), "zip");
+
+  return registry;
+}
+
+}  // namespace credentio
diff --git a/formats/core_registry.h b/formats/core_registry.h
new file mode 100644
index 0000000..4d514e9
--- /dev/null
+++ b/formats/core_registry.h
@@ -0,0 +1,29 @@
+// 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_FORMATS_CORE_REGISTRY_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_CORE_REGISTRY_H_
+
+#include <memory>
+
+#include "formats/registry.h"
+
+namespace credentio {
+
+std::unique_ptr<FormatRegistry> CreateCoreFormatRegistry();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_CORE_REGISTRY_H_
diff --git a/formats/extractor.h b/formats/extractor.h
new file mode 100644
index 0000000..5086875
--- /dev/null
+++ b/formats/extractor.h
@@ -0,0 +1,82 @@
+// 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_FORMATS_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// FormatExtractor for finding the C2PA Manifest Store in an asset, without
+// parsing or verifying the manifest store.
+class FormatExtractor {
+ public:
+  virtual ~FormatExtractor() = default;
+
+  struct ExtractOptions {
+    bool requires_c2pa = true;
+    int64_t end_offset = -1;
+  };
+
+  // Returns the extracted C2PA payload from the supplied asset if there is only
+  // one found. Returns a NotFoundError for 0 or 2+ C2PA payloads. Returns an
+  // error if there was a problem extracting the payload.
+  virtual absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const = 0;
+
+  // Returns the location of the structure in the supplied asset that contains
+  // the C2PA Manifest Store. This includes format-specific wrapping, so for
+  // example in a JPEG it would cover the sequence of contiguous segments that
+  // contain the fragments of the manifest store.
+  //
+  // If requires_c2pa is true, then an error will be returned if the C2PA
+  // Manifest Store is not found.
+  virtual absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const = 0;
+
+  // Returns the extracted boxes from the supplied asset. If requires_c2pa is
+  // true, then an error will be returned if the C2PA Manifest Store is not
+  // found.
+  virtual absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const = 0;
+
+  // Lightweight check to determine if the payload may contain a C2PA
+  // Manifest Store. This payload is the data contained within defined sequence
+  // of bytes within an asset.
+  // Example:
+  // - For JPEG, this is the data contained within a JPEG segment minus the
+  // marker and segment size.
+  // - For BMFF, this is the data contained within a BMFF box minus the box
+  // header.
+  // - For PNG, this is the data contained within a PNG chunk minus the chunk
+  // length, type and CRC.
+  // - For RIFF, this is the data contained within a RIFF chunk minus the chunk
+  // length and type.
+  virtual bool MightBeC2paManifestStore(absl::string_view payload) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_H_
diff --git a/formats/extractor_result.h b/formats/extractor_result.h
new file mode 100644
index 0000000..37923ff
--- /dev/null
+++ b/formats/extractor_result.h
@@ -0,0 +1,49 @@
+// 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_FORMATS_EXTRACTOR_RESULT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_RESULT_H_
+
+#include <ostream>
+#include <string>
+
+#include "formats/asset_byte_info.h"
+
+namespace credentio {
+
+// Result of the extraction containing the extracted C2PA Manifest Store and
+// any segments found in the asset.
+struct ExtractorResult {
+  std::string manifest_store;
+  AssetByteInfo asset_byte_info;
+
+  bool operator==(const ExtractorResult& other) const {
+    return manifest_store == other.manifest_store &&
+           asset_byte_info == other.asset_byte_info;
+  }
+  bool operator!=(const ExtractorResult& other) const {
+    return !(*this == other);
+  }
+};
+
+inline std::ostream& operator<<(std::ostream& os,
+                                const ExtractorResult& result) {
+  return os << "ExtractorResult{manifest_store: " << result.manifest_store
+            << ", asset_byte_info: " << result.asset_byte_info << "}";
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_EXTRACTOR_RESULT_H_
diff --git a/formats/format.h b/formats/format.h
new file mode 100644
index 0000000..2852283
--- /dev/null
+++ b/formats/format.h
@@ -0,0 +1,73 @@
+// 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_FORMATS_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_FORMAT_H_
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "formats/extractor.h"
+
+namespace credentio {
+
+struct FormatOptions {
+  std::unique_ptr<FormatAssessor> assessor;
+  std::unique_ptr<FormatExtractor> extractor;
+  std::vector<std::string> mime_types;
+};
+
+// Format is the entry point for all format-specific logic.
+class Format {
+ public:
+  static absl::StatusOr<std::unique_ptr<Format>> Create(FormatOptions options) {
+    if (options.assessor == nullptr) {
+      return absl::InvalidArgumentError("FormatAssessor is required");
+    }
+    if (options.extractor == nullptr) {
+      return absl::InvalidArgumentError("FormatExtractor is required");
+    }
+    if (options.mime_types.empty()) {
+      return absl::InvalidArgumentError("At least one mime type is required");
+    }
+    return std::unique_ptr<Format>(new Format(std::move(options)));
+  }
+  ~Format() = default;
+
+  const FormatAssessor* assessor() const { return assessor_.get(); }
+
+  const FormatExtractor* extractor() const { return extractor_.get(); }
+
+  const std::vector<std::string>& mime_types() const { return mime_types_; }
+
+ private:
+  explicit Format(FormatOptions options)
+      : assessor_(std::move(options.assessor)),
+        extractor_(std::move(options.extractor)),
+        mime_types_(std::move(options.mime_types)) {}
+
+  const std::unique_ptr<FormatAssessor> assessor_;
+  const std::unique_ptr<FormatExtractor> extractor_;
+  const std::vector<std::string> mime_types_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_FORMAT_H_
diff --git a/formats/format_test.cc b/formats/format_test.cc
new file mode 100644
index 0000000..f72fb76
--- /dev/null
+++ b/formats/format_test.cc
@@ -0,0 +1,114 @@
+// 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 "formats/format.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/assessor.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+class FakeAssessor : public FormatAssessor {
+ public:
+  FakeAssessor() = default;
+  ~FakeAssessor() override = default;
+
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override {
+    return absl::UnimplementedError("Not implemented");
+  }
+};
+
+class FakeExtractor : public FormatExtractor {
+ public:
+  FakeExtractor() = default;
+  ~FakeExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override {
+    return absl::UnimplementedError("Not implemented");
+  }
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError("Not implemented");
+  }
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError("Not implemented");
+  }
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override {
+    return true;
+  }
+};
+
+TEST(FormatTest, FailsIfNoAssessor) {
+  EXPECT_THAT(Format::Create(FormatOptions{
+                  .extractor = std::make_unique<FakeExtractor>(),
+                  .mime_types = {"jpeg"},
+              }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("FormatAssessor is required")));
+}
+
+TEST(FormatTest, FailsIfNoExtractor) {
+  EXPECT_THAT(Format::Create(FormatOptions{
+                  .assessor = std::make_unique<FakeAssessor>(),
+                  .mime_types = {"jpeg"},
+              }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("FormatExtractor is required")));
+}
+
+TEST(FormatTest, FailsIfNoMimeTypes) {
+  EXPECT_THAT(Format::Create(FormatOptions{
+                  .assessor = std::make_unique<FakeAssessor>(),
+                  .extractor = std::make_unique<FakeExtractor>(),
+              }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("At least one mime type is required")));
+}
+
+TEST(FormatTest, Succeeds) {
+  EXPECT_THAT(Format::Create(FormatOptions{
+                  .assessor = std::make_unique<FakeAssessor>(),
+                  .extractor = std::make_unique<FakeExtractor>(),
+                  .mime_types = {"jpeg"},
+              }),
+              IsOk());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/gif/BUILD b/formats/gif/BUILD
new file mode 100644
index 0000000..19badc8
--- /dev/null
+++ b/formats/gif/BUILD
@@ -0,0 +1,149 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "reader",
+    srcs = ["reader.cc"],
+    hdrs = ["reader.h"],
+    deps = [
+        ":constants",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "reader_test",
+    srcs = ["reader_test.cc"],
+    deps = [
+        ":constants",
+        ":reader",
+        ":test_builder",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/base:types",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "test_builder",
+    testonly = True,
+    srcs = ["test_builder.cc"],
+    hdrs = ["test_builder.h"],
+    deps = [
+        ":constants",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = ["@abseil-cpp//absl/strings:string_view"],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":constants",
+        ":reader",
+        "//constants:labels",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    deps = [
+        ":constants",
+        ":extractor",
+        ":test_builder",
+        "//formats:asset_box",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//formats:extractor_result",
+        "//testing:jumbf_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/base:types",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        ":constants",
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/gif/assessor.cc b/formats/gif/assessor.cc
new file mode 100644
index 0000000..c524b80
--- /dev/null
+++ b/formats/gif/assessor.cc
@@ -0,0 +1,48 @@
+// 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 "formats/gif/assessor.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/gif/constants.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+absl::StatusOr<bool> GifAssessor::IsSupported(riegeli::Reader& input) const {
+  if (input.Size() < kMinGifSize) {
+    return false;
+  }
+
+  int64_t starting_position = input.pos();
+
+  std::string payload;
+  if (!input.Read(kGifHeaderBlock.size(), payload)) {
+    return input.StatusOrAnnotate(
+        absl::InternalError("Failed to read GIF header block"));
+  }
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(
+        absl::InternalError("Failed to seek to starting position"));
+  }
+
+  return payload == kGifHeaderBlock;
+}
+
+}  // namespace credentio
diff --git a/formats/gif/assessor.h b/formats/gif/assessor.h
new file mode 100644
index 0000000..7ee3c18
--- /dev/null
+++ b/formats/gif/assessor.h
@@ -0,0 +1,34 @@
+// 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_FORMATS_GIF_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Assessor for the GIF image format.
+class GifAssessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_ASSESSOR_H_
diff --git a/formats/gif/assessor_test.cc b/formats/gif/assessor_test.cc
new file mode 100644
index 0000000..fea0f65
--- /dev/null
+++ b/formats/gif/assessor_test.cc
@@ -0,0 +1,66 @@
+// 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 "formats/gif/assessor.h"
+
+#include <string>
+
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+
+TEST(IsSupportedTest, FailureTooShort) {
+  std::string image = "test";
+  riegeli::StringReader<> input(image);
+
+  EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, FailureIncorrectHeader) {
+  std::string image = "this_is_not_a_gif";
+  riegeli::StringReader<> input(image);
+
+  EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, Succeeds) {
+  std::string image = "GIF89athis_is_a_gif";
+  riegeli::StringReader<> input(image);
+
+  EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, SucceedsAtOffset2) {
+  std::string image = "abGIF89athis_is_a_gif";
+  riegeli::StringReader<> input(image);
+
+  // Invalid at 0
+  EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(GifAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/gif/constants.h b/formats/gif/constants.h
new file mode 100644
index 0000000..2bb4dab
--- /dev/null
+++ b/formats/gif/constants.h
@@ -0,0 +1,65 @@
+// 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_FORMATS_GIF_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_CONSTANTS_H_
+
+#include <cstdint>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// GIF format: https://giflib.sourceforge.net/whatsinagif/bits_and_bytes.html
+// C2PA only supports GIF89a format:
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_gif_specific_handling
+constexpr absl::string_view kGifHeaderBlock = "GIF89a";
+
+// Header Block (6) + Logical Screen Descriptor (7) + Trailer (1)
+constexpr int64_t kMinGifSize = 6 + 7 + 1;
+
+// Introducers (First Byte)
+constexpr uint8_t kGifExtensionIntroducer = 0x21;
+constexpr uint8_t kGifImageDescriptorIntroducer = 0x2C;
+constexpr uint8_t kGifTrailerIntroducer = 0x3B;
+
+// Extension Label (Second Byte when introducer is 0x21)
+constexpr uint8_t kGifExtensionPlainText = 0x01;
+constexpr uint8_t kGifExtensionGraphicsControl = 0xF9;
+constexpr uint8_t kGifExtensionComment = 0xFE;
+constexpr uint8_t kGifExtensionApplication = 0xFF;
+
+// Corresponding C2PA Box Labels
+constexpr absl::string_view kGifLogicalScreenDescriptorLabel = "LSD";
+constexpr absl::string_view kGifImageDescriptorLabel = "2C";
+constexpr absl::string_view kGifTrailerLabel = "3B";
+constexpr absl::string_view kGifImageDataLabel = "TBID";
+constexpr absl::string_view kGifExtensionPlainTextLabel = "2101";
+constexpr absl::string_view kGifExtensionGraphicsControlLabel = "21F9";
+constexpr absl::string_view kGifExtensionCommentLabel = "21FE";
+constexpr absl::string_view kGifExtensionApplicationLabel = "21FF";
+constexpr absl::string_view kGifAfterLabel = "c2pa.after";
+
+// C2PA Block Information
+// Embedding Information:
+// https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_embedding_manifests_into_gifs
+constexpr uint8_t kGifC2paExtensionInfoSize = 0x0B;
+constexpr absl::string_view kGifC2paIdentifier = "C2PA_GIF";
+constexpr uint16_t kGifC2paAuthenticationCode = 0x0100;
+constexpr absl::string_view kGifC2paLabel = "C2PA";
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_CONSTANTS_H_
diff --git a/formats/gif/extractor.cc b/formats/gif/extractor.cc
new file mode 100644
index 0000000..e74c306
--- /dev/null
+++ b/formats/gif/extractor.cc
@@ -0,0 +1,202 @@
+// 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 "formats/gif/extractor.h"
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/gif/constants.h"
+#include "formats/gif/reader.h"
+#include "jumbf/utils.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+constexpr uint64_t kMaxPayloadSize = 10 * 1024 * 1024;  // 10 MiB
+
+absl::StatusOr<std::string> DecodeDataBlocks(absl::string_view data) {
+  std::string result;
+  result.reserve(data.size());
+
+  for (size_t i = 0; i < data.size();) {
+    uint8_t block_size = data[i];
+    i++;
+    if (block_size == 0) {
+      break;
+    }
+    if (i + block_size > data.size()) {
+      return absl::InvalidArgumentError("Invalid block size in GIF C2PA data");
+    }
+    result.append(data.substr(i, block_size));
+    i += block_size;
+  }
+  return result;
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> GifExtractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  ABSL_ASSIGN_OR_RETURN(
+      std::optional<ByteRange> c2pa_location,
+      ExtractManifestStoreLocation(input, {.requires_c2pa = true}));
+  if (!c2pa_location.has_value()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+
+  // The block starts with two markers identifying it as an application ext.
+  // Then there is a single byte identifying the size of the extension info.
+  // After that is the extension info itself and then starts the alternating
+  // size and data bytes for the manifest store.
+  int64_t manifest_data_offset = c2pa_location->offset + 14;
+  int64_t manifest_data_length = c2pa_location->length - 14;
+
+  if (manifest_data_length < 0 || manifest_data_length > kMaxPayloadSize) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Manifest store is too large: ", manifest_data_length,
+                     " > ", kMaxPayloadSize));
+  }
+
+  if (!input.Seek(manifest_data_offset)) {
+    return input.StatusOrAnnotate(
+        absl::InvalidArgumentError("Failed to seek to manifest data offset"));
+  }
+  std::string encoded_manifest_store;
+  if (!input.Read(manifest_data_length, encoded_manifest_store)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read manifest data from input"));
+  }
+
+  // Data is stored as blocks of size-prefixed data.
+  return DecodeDataBlocks(encoded_manifest_store);
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+GifExtractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                           ExtractOptions options) const {
+  std::optional<GifBlock> c2pa_block = std::nullopt;
+  absl::Status iteration_status = IterateOverGifBlocks(
+      input,
+      [&options, &c2pa_block](const GifBlock& block) -> absl::StatusOr<bool> {
+        if (block.offset >= options.end_offset ||
+            block.length > options.end_offset - block.offset) {
+          // We've gone past the declared end of the input.
+          return false;  // stop processing
+        }
+        if (block.type == kGifImageDescriptorLabel) {
+          // The manifest store must be before the first image descriptor.
+          // https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_embedding_manifests_into_gifs
+          return false;  // stop processing
+        }
+        if (block.type == "C2PA") {
+          if (c2pa_block.has_value()) {
+            return absl::NotFoundError("Multiple manifest stores found");
+          }
+          c2pa_block = std::move(block);
+        }
+        return true;
+      },
+      options.end_offset);
+  ABSL_RETURN_IF_ERROR(iteration_status);
+  if (!c2pa_block.has_value()) {
+    if (options.requires_c2pa) {
+      return absl::NotFoundError("No manifest store found");
+    }
+    return std::nullopt;
+  }
+  return ByteRange{.offset = c2pa_block->offset, .length = c2pa_block->length};
+}
+
+absl::StatusOr<std::vector<AssetBox>> GifExtractor::ExtractBoxes(
+    riegeli::Reader& input, ExtractOptions options) const {
+  if (!input.SupportsSize() || !input.Size().has_value()) {
+    return absl::InvalidArgumentError(
+        "Input does not support size or size is unknown");
+  }
+  uint64_t end_offset = input.Size().value();
+  if (options.end_offset >= 0) {
+    end_offset =
+        std::min(end_offset, static_cast<uint64_t>(options.end_offset));
+  }
+
+  std::vector<AssetBox> blocks;
+  bool c2pa_block_found = false;
+  absl::Status iteration_status = IterateOverGifBlocks(
+      input,
+      [&end_offset, &c2pa_block_found,
+       &blocks](const GifBlock& block) -> absl::StatusOr<bool> {
+        if (block.offset >= end_offset) {
+          // The box is past the declared end of the input.
+          return false;  // stop processing
+        }
+        if (block.length > end_offset - block.offset) {
+          // The box extends past the declared end of the input.
+          if (block.type == "c2pa.after") {
+            // We can truncate the c2pa.after block to the end of the input.
+            blocks.push_back(AssetBox{
+                .identifier = block.type,
+                .byte_range = {.offset = block.offset,
+                               .length = end_offset - block.offset},
+            });
+            return false;
+          }
+          return absl::InvalidArgumentError(
+              "Block extends past the declared end of the input");
+        }
+        if (block.type == "C2PA") {
+          if (c2pa_block_found) {
+            return absl::NotFoundError("Multiple manifest stores found");
+          }
+          c2pa_block_found = true;
+        }
+
+        blocks.push_back(AssetBox{
+            .identifier = block.type,
+            .byte_range = {.offset = block.offset, .length = block.length},
+        });
+        return true;
+      },
+      end_offset);
+  ABSL_RETURN_IF_ERROR(iteration_status);
+  if (!c2pa_block_found && options.requires_c2pa) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return blocks;
+}
+
+bool GifExtractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+
+}  // namespace credentio
diff --git a/formats/gif/extractor.h b/formats/gif/extractor.h
new file mode 100644
index 0000000..71afabb
--- /dev/null
+++ b/formats/gif/extractor.h
@@ -0,0 +1,53 @@
+// 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_FORMATS_GIF_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Implements the C2PA Manifest Store extractor for GIF files as described in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_png
+class GifExtractor : public FormatExtractor {
+ public:
+  GifExtractor() = default;
+  ~GifExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_EXTRACTOR_H_
diff --git a/formats/gif/extractor_test.cc b/formats/gif/extractor_test.cc
new file mode 100644
index 0000000..e879961
--- /dev/null
+++ b/formats/gif/extractor_test.cc
@@ -0,0 +1,394 @@
+// 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 "formats/gif/extractor.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/asset_byte_info.h"
+#include "formats/byte_range.h"
+#include "formats/extractor_result.h"
+#include "formats/gif/constants.h"
+#include "formats/gif/test_builder.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/jumbf_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+class NoSizeStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return false; }
+};
+
+class SizeNotValuedStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return true; }
+
+ protected:
+  std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; }
+};
+
+constexpr int64_t kMaxChunkBytes = 2 * 1048576;  // 2MiB
+
+struct TestCase {
+  std::string name;
+  std::string contents;
+  absl::StatusOr<ExtractorResult> result;
+  uint32_t max_chunk_bytes = kMaxChunkBytes;
+};
+
+class ExtractorTest : public testing::TestWithParam<TestCase> {};
+
+INSTANTIATE_TEST_SUITE_P(
+    ExtractorTests, ExtractorTest,
+    testing::ValuesIn({
+        TestCase{
+            .name = "Valid",
+            .contents =
+                []() {
+                  TestGifBuilder builder;
+                  builder.AddGifHeader();
+                  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+                  builder.AddBlock(kGifExtensionIntroducer,
+                                   kGifExtensionApplication,
+                                   std::string(kGifC2paIdentifier) + "___",
+                                   "manifest_store");
+                  return builder.GetAsset();
+                }(),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "manifest_store",
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 13,
+                                                                 .length = 30},
+                            .boxes = {AssetBox{.identifier = "GIF89a",
+                                               .byte_range = {.offset = 0,
+                                                              .length = 6}},
+                                      AssetBox{.identifier = "LSD",
+                                               .byte_range = {.offset = 6,
+                                                              .length = 7}},
+                                      AssetBox{.identifier = "C2PA",
+                                               .byte_range = {.offset = 13,
+                                                              .length = 30}}},
+                        },
+                },
+        },
+        TestCase{
+            .name = "NoC2pa",
+            .contents =
+                []() {
+                  TestGifBuilder builder;
+                  builder.AddGifHeader();
+                  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+                  return builder.GetAsset();
+                }(),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "MultipleC2pa",
+            .contents =
+                []() {
+                  TestGifBuilder builder;
+                  builder.AddGifHeader();
+                  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+                  builder.AddBlock(
+                      kGifExtensionIntroducer, kGifExtensionApplication,
+                      std::string(kGifC2paIdentifier) + "___", "first_store");
+                  builder.AddBlock(
+                      kGifExtensionIntroducer, kGifExtensionApplication,
+                      std::string(kGifC2paIdentifier) + "___", "second_store");
+                  return builder.GetAsset();
+                }(),
+            .result = absl::NotFoundError("Multiple manifest stores found"),
+        },
+    }),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+TEST_P(ExtractorTest, Extract) {
+  const TestCase& param = GetParam();
+  const absl::StatusOr<ExtractorResult>& expected = param.result;
+  riegeli::StringReader<> input(param.contents);
+  absl::StatusOr<std::string> result =
+      GifExtractor().ExtractManifestStore(input);
+
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(expected->manifest_store));
+  } else {
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) {
+  const TestCase& param = GetParam();
+  const absl::StatusOr<ExtractorResult>& expected = param.result;
+  std::string contents = "padding" + param.contents;
+  riegeli::StringReader<> input(contents);
+  ASSERT_TRUE(input.Seek(7));
+  absl::StatusOr<std::string> result =
+      GifExtractor().ExtractManifestStore(input);
+
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(expected->manifest_store));
+  } else {
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffixPadded) {
+  const TestCase& param = GetParam();
+  const absl::StatusOr<ExtractorResult>& expected = param.result;
+  std::string contents = param.contents + "padding";
+  riegeli::StringReader<> input(contents);
+
+  absl::StatusOr<std::optional<ByteRange>> result =
+      GifExtractor().ExtractManifestStoreLocation(
+          input, {.end_offset = static_cast<int64_t>(contents.size() - 7)});
+
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(
+                            expected->asset_byte_info.manifest_store_location));
+  } else {
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxesWithSuffixPadded) {
+  const TestCase& param = GetParam();
+  const absl::StatusOr<ExtractorResult>& expected = param.result;
+  std::string contents = param.contents + "padding";
+  riegeli::StringReader<> input(contents);
+
+  absl::StatusOr<std::vector<AssetBox>> result = GifExtractor().ExtractBoxes(
+      input, {.end_offset = static_cast<int64_t>(contents.size()) - 7});
+
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(expected->asset_byte_info.boxes));
+  } else {
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocationRequiresC2pa) {
+  const TestCase& param = GetParam();
+  const absl::StatusOr<ExtractorResult>& expected = param.result;
+  riegeli::StringReader<> input(param.contents);
+  absl::StatusOr<std::optional<ByteRange>> result =
+      GifExtractor().ExtractManifestStoreLocation(input, {});
+
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(
+                            expected->asset_byte_info.manifest_store_location));
+  } else {
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocationDoesNotRequireC2pa) {
+  const TestCase& param = GetParam();
+  const absl::StatusOr<ExtractorResult>& expected = param.result;
+  riegeli::StringReader<> input(param.contents);
+  absl::StatusOr<std::optional<ByteRange>> result =
+      GifExtractor().ExtractManifestStoreLocation(input,
+                                                  {.requires_c2pa = false});
+
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(
+                            expected->asset_byte_info.manifest_store_location));
+  } else if (expected.status().code() == absl::StatusCode::kNotFound &&
+             expected.status().message() != "Multiple manifest stores found") {
+    EXPECT_THAT(result, IsOkAndHolds(std::nullopt));
+  } else {
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxes) {
+  const TestCase& param = GetParam();
+  const absl::StatusOr<ExtractorResult>& expected = param.result;
+  riegeli::StringReader<> input(param.contents);
+  absl::StatusOr<std::vector<AssetBox>> result =
+      GifExtractor().ExtractBoxes(input, {});
+
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(expected->asset_byte_info.boxes));
+  } else {
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
+  }
+}
+
+TEST(GifExtractorTest, FailsWhenSizeNotSupported) {
+  std::string contents = "test";
+  NoSizeStringReader input(contents);
+
+  GifExtractor extractor;
+  EXPECT_THAT(extractor.ExtractBoxes(input, {}),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Input does not support size")));
+}
+
+TEST(GifExtractorTest, FailsWhenSizeHasNoValue) {
+  std::string contents = "test";
+  SizeNotValuedStringReader input(contents);
+
+  GifExtractor extractor;
+  EXPECT_THAT(extractor.ExtractBoxes(input, {}),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Input does not support size")));
+}
+
+TEST(GifExtractorTest,
+     ExtractManifestStoreLocationStopsWhenBlockExceedsEndOffset) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   std::string(kGifC2paIdentifier) + "___", "manifest_store");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  // LSD is at [6, 13). Set end_offset to 10.
+  // It should stop at LSD and return NotFoundError because C2PA (at 13) is not
+  // reached.
+  absl::StatusOr<std::optional<ByteRange>> result =
+      GifExtractor().ExtractManifestStoreLocation(input, {.end_offset = 10});
+
+  EXPECT_THAT(result, StatusIs(absl::StatusCode::kNotFound,
+                               HasSubstr("No manifest store found")));
+}
+
+TEST(GifExtractorTest, ExtractBoxesFailsWhenBlockExceedsEndOffset) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  // LSD is at [6, 13). Set end_offset to 10.
+  // It should fail with InvalidArgumentError because LSD extends past 10.
+  absl::StatusOr<std::vector<AssetBox>> result =
+      GifExtractor().ExtractBoxes(input, {.end_offset = 10});
+
+  EXPECT_THAT(
+      result,
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Block extends past the declared end of the input")));
+}
+
+TEST(GifExtractorTest,
+     ExtractManifestStoreLocationIgnoresC2paAfterImageDescriptor) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddImageDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   std::string(kGifC2paIdentifier) + "___", "manifest_store");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  // It should stop at the image descriptor and not find the C2PA block.
+  absl::StatusOr<std::optional<ByteRange>> result =
+      GifExtractor().ExtractManifestStoreLocation(input, {});
+
+  EXPECT_THAT(result, StatusIs(absl::StatusCode::kNotFound,
+                               HasSubstr("No manifest store found")));
+}
+
+class FakeGifExtractor : public GifExtractor {
+ public:
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    if (fake_location_.has_value()) {
+      return fake_location_;
+    }
+    return GifExtractor::ExtractManifestStoreLocation(input, options);
+  }
+  void SetFakeLocation(ByteRange loc) { fake_location_ = loc; }
+
+ private:
+  mutable std::optional<ByteRange> fake_location_;
+};
+
+TEST(GifExtractorTest, ExtractManifestStoreFailsOnMalformedC2paData) {
+  std::string contents;
+  contents.resize(14, 'A');  // 14 bytes of padding
+  contents += '\005';        // block size 5
+  contents += "abc";         // but only 3 bytes
+
+  riegeli::StringReader<> input(contents);
+
+  FakeGifExtractor extractor;
+  extractor.SetFakeLocation(ByteRange{.offset = 0, .length = 18});
+
+  absl::StatusOr<std::string> result = extractor.ExtractManifestStore(input);
+
+  EXPECT_THAT(result,
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Invalid block size in GIF C2PA data")));
+}
+
+TEST(GifExtractorTest, ExtractManifestStoreFailsOnOversizedData) {
+  std::string contents(10, 'A');
+  riegeli::StringReader<> input(contents);
+
+  FakeGifExtractor extractor;
+  extractor.SetFakeLocation(
+      ByteRange{.offset = 0, .length = 11 * 1024 * 1024 + 14});
+
+  absl::StatusOr<std::string> result = extractor.ExtractManifestStore(input);
+
+  EXPECT_THAT(result, StatusIs(absl::StatusCode::kInvalidArgument,
+                               HasSubstr("Manifest store is too large")));
+}
+
+TEST(GifExtractorTest, MightBeC2paManifestStore) {
+  EXPECT_TRUE(GifExtractor().MightBeC2paManifestStore(
+      CreateStartOfManifestStorePayload()));
+  EXPECT_FALSE(GifExtractor().MightBeC2paManifestStore("not a manifest store"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/gif/format.cc b/formats/gif/format.cc
new file mode 100644
index 0000000..60b9cfd
--- /dev/null
+++ b/formats/gif/format.cc
@@ -0,0 +1,35 @@
+// 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 "formats/gif/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+#include "formats/gif/assessor.h"
+#include "formats/gif/extractor.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateGifFormat() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<GifAssessor>(),
+      .extractor = std::make_unique<GifExtractor>(),
+      .mime_types = {"image/gif"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/gif/format.h b/formats/gif/format.h
new file mode 100644
index 0000000..e42c6ef
--- /dev/null
+++ b/formats/gif/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_GIF_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateGifFormat();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_FORMAT_H_
diff --git a/formats/gif/reader.cc b/formats/gif/reader.cc
new file mode 100644
index 0000000..37889ff
--- /dev/null
+++ b/formats/gif/reader.cc
@@ -0,0 +1,276 @@
+// 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 "formats/gif/reader.h"
+
+#include <cstdint>
+#include <string>
+#include <utility>
+
+#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 "formats/gif/constants.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+struct ColorTable {
+  bool has_table;
+  uint8_t size;
+
+  static ColorTable FromPacked(uint8_t packed) {
+    return {
+        .has_table = (packed & 0x80) != 0,
+        .size = static_cast<uint8_t>(packed & 0x07),
+    };
+  }
+};
+
+absl::Status SeekToEndOfData(riegeli::Reader& input) {
+  uint8_t size = 0x00;
+  do {
+    if (!input.ReadByte(size)) {
+      return input.StatusOrAnnotate(absl::DataLossError("Failed to read byte"));
+    }
+    if (!input.ok()) {
+      return input.status();
+    }
+
+    if (size > 0) {
+      if (!input.Skip(size)) {
+        return absl::InternalError("Failed to seek to offset");
+      }
+      if (!input.ok()) {
+        return input.status();
+      }
+    }
+  } while (size > 0);
+
+  return absl::OkStatus();
+}
+
+absl::StatusOr<GifBlock> ReadHeader(riegeli::Reader& input) {
+  GifBlock block = {.offset = input.pos()};
+
+  std::string data;
+  if (!input.Read(kGifHeaderBlock.size(), data)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read GIF header block"));
+  }
+  if (data != kGifHeaderBlock) {
+    return absl::InvalidArgumentError("Invalid GIF header block");
+  }
+
+  block.length = data.size();
+  block.type = data;
+  return std::move(block);
+}
+
+absl::Status PopulateDescriptor(riegeli::Reader& input,
+                                int64_t packed_byte_offset, GifBlock& block) {
+  std::string data;
+  if (!input.Read(block.length, data)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read descriptor"));
+  }
+
+  ColorTable packed = ColorTable::FromPacked(data[packed_byte_offset]);
+
+  if (packed.has_table) {
+    int64_t ct_length = 3 * (1 << (packed.size + 1));
+    block.length += ct_length;
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status PopulateLogicalDescriptor(riegeli::Reader& input,
+                                       GifBlock& block) {
+  block.length = 7;
+  block.type = std::string(kGifLogicalScreenDescriptorLabel);
+  return PopulateDescriptor(input, 4, block);
+}
+
+absl::Status PopulateImageDescriptor(riegeli::Reader& input, GifBlock& block) {
+  // Backup one byte so we can read the introducer byte.
+  if (!input.Seek(input.pos() - 1)) {
+    return absl::InternalError("Failed to seek to offset");
+  }
+  block.length = 10;
+  block.type = std::string(kGifImageDescriptorLabel);
+  return PopulateDescriptor(input, 9, block);
+}
+
+absl::Status PopulateBlock(riegeli::Reader& input, GifBlock& block) {
+  ABSL_RETURN_IF_ERROR(SeekToEndOfData(input));
+  block.length = input.pos() - block.offset;
+  return absl::OkStatus();
+}
+
+absl::Status PopulateApplicationExtension(riegeli::Reader& input,
+                                          GifBlock& block) {
+  int64_t next_size_offset = input.pos();
+
+  uint8_t extension_info_size = 0x00;
+  if (!input.ReadByte(extension_info_size)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read extension info size"));
+  }
+
+  next_size_offset += 1 + extension_info_size;
+
+  if (extension_info_size == kGifC2paExtensionInfoSize) {
+    std::string data;
+    if (!input.Read(kGifC2paIdentifier.size(), data)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read c2pa identifier"));
+    }
+    if (data == kGifC2paIdentifier) {
+      block.type = kGifC2paLabel;
+    }
+  }
+
+  // Seek past the remaining Application Extension Information
+  if (!input.Seek(next_size_offset)) {
+    return absl::InternalError("Failed to seek to offset");
+  }
+
+  return PopulateBlock(input, block);
+}
+
+}  // namespace
+
+absl::Status IterateOverGifBlocks(riegeli::Reader& input,
+                                  GifBlockProcessor processor,
+                                  int64_t end_offset) {
+  if (!input.SupportsSize() || !input.Size().has_value()) {
+    return absl::InvalidArgumentError(
+        "Input does not support size or size is unknown");
+  }
+
+  uint64_t local_end_offset =
+      end_offset < 0 ? input.Size().value() : end_offset;
+
+  ABSL_ASSIGN_OR_RETURN(auto header, ReadHeader(input));
+  ABSL_ASSIGN_OR_RETURN(bool should_continue, processor(header));
+  ABSL_RETURN_IF_ERROR(input.status());
+  if (!should_continue) {
+    return absl::OkStatus();
+  }
+
+  if (input.pos() >= local_end_offset) {
+    return absl::OkStatus();
+  }
+
+  GifBlock logical_descriptor = {.offset = input.pos()};
+  ABSL_RETURN_IF_ERROR(PopulateLogicalDescriptor(input, logical_descriptor));
+  ABSL_ASSIGN_OR_RETURN(should_continue, processor(logical_descriptor));
+  ABSL_RETURN_IF_ERROR(input.status());
+  if (!should_continue) {
+    return absl::OkStatus();
+  }
+
+  if (!input.Seek(logical_descriptor.offset + logical_descriptor.length)) {
+    return input.StatusOrAnnotate(absl::InternalError(
+        "Failed to seek to offset after logical descriptor"));
+  }
+
+  while (input.pos() < local_end_offset) {
+    GifBlock block = {.offset = input.pos()};
+
+    uint8_t introducer = 0x00;
+    if (!input.ReadByte(introducer)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read introducer"));
+    }
+
+    switch (introducer) {
+      case kGifExtensionIntroducer: {
+        // is extension
+        uint8_t extension_type = 0x00;
+        if (!input.ReadByte(extension_type)) {
+          return input.StatusOrAnnotate(
+              absl::DataLossError("Failed to read extension type"));
+        }
+
+        switch (extension_type) {
+          case kGifExtensionPlainText:
+            block.type = std::string(kGifExtensionPlainTextLabel);
+            ABSL_RETURN_IF_ERROR(PopulateBlock(input, block));
+            break;
+          case kGifExtensionGraphicsControl:
+            block.type = std::string(kGifExtensionGraphicsControlLabel);
+            ABSL_RETURN_IF_ERROR(PopulateBlock(input, block));
+            break;
+          case kGifExtensionComment:
+            block.type = std::string(kGifExtensionCommentLabel);
+            ABSL_RETURN_IF_ERROR(PopulateBlock(input, block));
+            break;
+          case kGifExtensionApplication:
+            block.type = std::string(kGifExtensionApplicationLabel);
+            ABSL_RETURN_IF_ERROR(PopulateApplicationExtension(input, block));
+            break;
+          default:
+            return absl::UnimplementedError(absl::StrFormat(
+                "Extension Type not supported: %#04x", extension_type));
+        }
+        break;
+      }
+      case kGifImageDescriptorIntroducer: {
+        ABSL_RETURN_IF_ERROR(PopulateImageDescriptor(input, block));
+        break;
+      }
+      case kGifTrailerIntroducer: {
+        block.length = 1;
+        block.type = std::string(kGifTrailerLabel);
+        break;
+      }
+      default:
+        block.type = std::string(kGifImageDataLabel);
+        ABSL_RETURN_IF_ERROR(PopulateBlock(input, block));
+    }
+
+    ABSL_ASSIGN_OR_RETURN(should_continue, processor(block));
+    ABSL_RETURN_IF_ERROR(input.status());
+    if (!should_continue) {
+      return absl::OkStatus();
+    }
+
+    if (!input.Seek(block.offset + block.length)) {
+      return input.StatusOrAnnotate(
+          absl::InternalError("Failed to seek to offset"));
+    }
+
+    if (block.type == kGifTrailerLabel) {
+      break;
+    }
+  }
+
+  if (input.pos() < local_end_offset) {
+    GifBlock after_label = {.offset = input.pos(),
+                            .length = local_end_offset - input.pos(),
+                            .type = "c2pa.after"};
+    return processor(after_label).status();
+  }
+
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/formats/gif/reader.h b/formats/gif/reader.h
new file mode 100644
index 0000000..70e5bb1
--- /dev/null
+++ b/formats/gif/reader.h
@@ -0,0 +1,51 @@
+// 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_FORMATS_GIF_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_READER_H_
+
+#include <cstdint>
+#include <string>
+
+#include "absl/functional/function_ref.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// GIF Reader for C2PA, holds onto a reference to a riegeli::Reader which
+// must outlive the reader.
+struct GifBlock {
+  uint64_t offset;
+  uint64_t length;
+  std::string type;
+
+  bool operator==(const GifBlock& other) const {
+    return offset == other.offset && length == other.length &&
+           type == other.type;
+  }
+};
+
+using GifBlockProcessor =
+    absl::FunctionRef<absl::StatusOr<bool>(const GifBlock&)>;
+
+absl::Status IterateOverGifBlocks(riegeli::Reader& input,
+                                  GifBlockProcessor processor,
+                                  int64_t end_offset = -1);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_READER_H_
diff --git a/formats/gif/reader_test.cc b/formats/gif/reader_test.cc
new file mode 100644
index 0000000..e4ce30d
--- /dev/null
+++ b/formats/gif/reader_test.cc
@@ -0,0 +1,517 @@
+// 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 "formats/gif/reader.h"
+
+#include <optional>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "formats/gif/constants.h"
+#include "formats/gif/test_builder.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+class NoSizeStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return false; }
+};
+
+class SizeNotValuedStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return true; }
+
+ protected:
+  std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; }
+};
+
+absl::StatusOr<GifBlock> GetLastBlock(riegeli::Reader& input) {
+  GifBlock last_block;
+  if (auto status = IterateOverGifBlocks(input,
+                                         [&last_block](const GifBlock& block) {
+                                           last_block = block;
+                                           return true;
+                                         });
+      !status.ok()) {
+    return status;
+  }
+  return last_block;
+}
+
+TEST(GifReaderTest, ReadsHeader) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 0,
+                                       .length = 6,
+                                       .type = "GIF89a",
+                                   }));
+}
+
+TEST(GifReaderTest, FailsOnInvalidHeader) {
+  std::string contents = "GIF87a";
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(GifReaderTest, ReadsLogicalDescriptorWithoutColorTable) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 6,
+                                       .length = 7,
+                                       .type = "LSD",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsLogicalDescriptorWithColorTable) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/true);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 6,
+                                       .length = 19,
+                                       .type = "LSD",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsPlainTextExtension) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info",
+                   "plain_text_data");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 28,
+                                       .type = "2101",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsInvalidExtensionIntroducer) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, 0x0d);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input),
+              StatusIs(absl::StatusCode::kUnimplemented,
+                       HasSubstr("Extension Type not supported: 0x0d")));
+}
+
+TEST(GifReaderTest, ReadsGraphicsControlExtension) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionGraphicsControl,
+                   "ext_info", "gce");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 16,
+                                       .type = "21F9",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsComment) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionComment, "ext_info",
+                   "comment");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 20,
+                                       .type = "21FE",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsTrailer) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifTrailerIntroducer);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 1,
+                                       .type = "3B",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsTableData) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(0x02, /*type=*/std::nullopt, /*info=*/std::nullopt,
+                   "this_is_some_image_data");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 26,
+                                       .type = "TBID",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsImageDescriptorWithoutColorTable) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddImageDescriptor(/*has_color_table=*/false);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 10,
+                                       .type = "2C",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsImageDescriptorWithColorTable) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddImageDescriptor(/*has_color_table=*/true);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 22,
+                                       .type = "2C",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsApplicationExtension) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   "ext_info", "data");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 17,
+                                       .type = "21FF",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsC2paExtension) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   std::string(kGifC2paIdentifier) + "___", "manifest_store");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 13,
+                                       .length = 30,
+                                       .type = "C2PA",
+                                   }));
+}
+
+TEST(GifReaderTest, ReadsPastTrailer) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifTrailerIntroducer);
+  std::string contents = builder.GetAsset() + "random_data_past_the_trailer";
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
+                                       .offset = 14,
+                                       .length = 28,
+                                       .type = "c2pa.after",
+                                   }));
+}
+
+TEST(GifReaderTest, FailsWhenSizeNotSupported) {
+  std::string contents = "test";
+  NoSizeStringReader input(contents);
+
+  EXPECT_THAT(
+      IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Input does not support size")));
+}
+
+TEST(GifReaderTest, FailsWhenSizeHasNoValue) {
+  std::string contents = "test";
+  SizeNotValuedStringReader input(contents);
+
+  EXPECT_THAT(
+      IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Input does not support size")));
+}
+
+TEST(GifReaderTest, StopsAtHeaderIfProcessorReturnsFalse) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  int call_count = 0;
+  EXPECT_THAT(IterateOverGifBlocks(input,
+                                   [&](const GifBlock& block) {
+                                     call_count++;
+                                     return false;
+                                   }),
+              IsOk());
+
+  EXPECT_EQ(call_count, 1);
+}
+
+TEST(GifReaderTest, StopsAtLogicalDescriptorIfProcessorReturnsFalse) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info",
+                   "plain_text_data");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  int call_count = 0;
+  EXPECT_THAT(IterateOverGifBlocks(input,
+                                   [&](const GifBlock& block) {
+                                     call_count++;
+                                     if (block.type == "LSD") {
+                                       return false;
+                                     }
+                                     return true;
+                                   }),
+              IsOk());
+
+  EXPECT_EQ(call_count, 2);
+}
+
+TEST(GifReaderTest, StopsInsideLoopIfProcessorReturnsFalse) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info",
+                   "plain_text_data");
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionComment, "ext_info",
+                   "comment");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  int call_count = 0;
+  EXPECT_THAT(
+      IterateOverGifBlocks(input,
+                           [&](const GifBlock& block) {
+                             call_count++;
+                             if (block.type == "2101") {  // PlainText extension
+                               return false;
+                             }
+                             return true;
+                           }),
+      IsOk());
+
+  EXPECT_EQ(call_count, 3);  // Header, LSD, PlainText
+}
+
+TEST(GifReaderTest, FailsWhenReaderFailsDuringLogicalDescriptor) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      IterateOverGifBlocks(input,
+                           [&](const GifBlock& block) {
+                             if (block.type == "GIF89a") {
+                               input.Fail(
+                                   absl::InternalError("forced read error"));
+                             }
+                             return true;
+                           }),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("forced read error")));
+}
+
+TEST(GifReaderTest, FailsWhenSeekFailsAfterLogicalDescriptor) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      IterateOverGifBlocks(input,
+                           [&](const GifBlock& block) {
+                             if (block.type == "LSD") {
+                               input.Fail(
+                                   absl::InternalError("forced seek error"));
+                             }
+                             return true;
+                           }),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("forced seek error")));
+}
+
+TEST(GifReaderTest, FailsWhenSeekFailsInsideLoop) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info",
+                   "plain_text_data");
+  std::string contents = builder.GetAsset();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(IterateOverGifBlocks(input,
+                                   [&](const GifBlock& block) {
+                                     if (block.type == "2101") {
+                                       input.Fail(absl::InternalError(
+                                           "forced seek error inside loop"));
+                                     }
+                                     return true;
+                                   }),
+              StatusIs(absl::StatusCode::kInternal,
+                       HasSubstr("forced seek error inside loop")));
+}
+
+TEST(GifReaderTest, FailsWhenSeekToEndOfDataFailsOnReadByte) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info",
+                   "plain_text_data");
+  std::string contents = builder.GetAsset();
+  // Truncate the last byte of the block, which should be the 0x00 termination
+  // byte.
+  contents.pop_back();
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read byte")));
+}
+
+TEST(GifReaderTest, FailsWhenSeekToEndOfDataFailsOnSkip) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionPlainText, "ext_info",
+                   "plain_text_data");
+  std::string contents = builder.GetAsset();
+  // Truncate the contents by 5 bytes (so skip will fail because we claim size
+  // is 15 but only 10 left).
+  for (int i = 0; i < 5; i++) {
+    contents.pop_back();
+  }
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }),
+      StatusIs(absl::StatusCode::kInternal,
+               HasSubstr("Failed to seek to offset")));
+}
+
+TEST(GifReaderTest, FailsOnHeaderTooShort) {
+  std::string contents = "GIF";
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }),
+      StatusIs(absl::StatusCode::kDataLoss,
+               HasSubstr("Failed to read GIF header block")));
+}
+
+TEST(GifReaderTest, FailsOnDescriptorTooShort) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  std::string contents = builder.GetAsset() + "123";  // Only 3 bytes for LSD
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      IterateOverGifBlocks(input, [](const GifBlock& block) { return true; }),
+      StatusIs(absl::StatusCode::kDataLoss,
+               HasSubstr("Failed to read descriptor")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/gif/test_builder.cc b/formats/gif/test_builder.cc
new file mode 100644
index 0000000..351e147
--- /dev/null
+++ b/formats/gif/test_builder.cc
@@ -0,0 +1,102 @@
+// 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 "formats/gif/test_builder.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "formats/gif/constants.h"
+
+namespace credentio {
+namespace {
+
+std::string EncodeDataToBlocks(std::string data,
+                               bool add_termination_block = true) {
+  std::vector<uint8_t> blocks;
+  blocks.reserve(data.size() + std::ceil(data.size() / 255.0) + 1);
+
+  for (int64_t i = 0; i < data.size(); i += 255) {
+    int64_t block_size = std::min<int64_t>(255, data.size() - i);
+    blocks.push_back(static_cast<uint8_t>(block_size));
+    blocks.insert(blocks.end(), data.begin() + i,
+                  data.begin() + i + block_size);
+  }
+  if (add_termination_block) {
+    blocks.push_back(0);  // Add a zero-length block to signify the end.
+  }
+  return std::string(blocks.begin(), blocks.end());
+}
+
+}  // namespace
+
+void TestGifBuilder::AddGifHeader() { gif_data_ += "GIF89a"; }
+
+void TestGifBuilder::AddLogicalDescriptor(bool has_color_table) {
+  uint8_t packedField = has_color_table ? 0x91 : 0x00;
+  uint8_t backgroundColorIndex = has_color_table ? 0x11 : 0x00;
+
+  std::vector<uint8_t> lsd = {1, 1, 2, 2, packedField, backgroundColorIndex, 0};
+  gif_data_ += std::string(lsd.begin(), lsd.end());
+
+  if (has_color_table) {
+    std::vector<uint8_t> color_table = {
+        0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
+    };
+    gif_data_ += std::string(color_table.begin(), color_table.end());
+  }
+}
+
+void TestGifBuilder::AddImageDescriptor(bool has_color_table) {
+  uint8_t packedField = has_color_table ? 0x91 : 0x00;
+
+  std::vector<uint8_t> lsd = {
+      kGifImageDescriptorIntroducer, 1, 1, 1, 1, 1, 1, 1, 1, packedField};
+  gif_data_ += std::string(lsd.begin(), lsd.end());
+
+  if (has_color_table) {
+    std::vector<uint8_t> color_table = {
+        0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
+    };
+    gif_data_ += std::string(color_table.begin(), color_table.end());
+  }
+}
+
+void TestGifBuilder::AddBlock(uint8_t introducer, std::optional<uint8_t> type,
+                              std::optional<std::string> info,
+                              std::optional<std::string> data) {
+  gif_data_ += static_cast<char>(introducer);
+
+  if (type.has_value()) {
+    gif_data_ += static_cast<char>(type.value());
+  }
+
+  if (info.has_value()) {
+    gif_data_ +=
+        EncodeDataToBlocks(info.value(), /*add_termination_block=*/false);
+  }
+
+  if (data.has_value()) {
+    gif_data_ += EncodeDataToBlocks(data.value());
+  }
+}
+
+std::string TestGifBuilder::GetAsset() { return gif_data_; }
+
+}  // namespace credentio
diff --git a/formats/gif/test_builder.h b/formats/gif/test_builder.h
new file mode 100644
index 0000000..288217f
--- /dev/null
+++ b/formats/gif/test_builder.h
@@ -0,0 +1,50 @@
+// 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_FORMATS_GIF_TEST_BUILDER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_TEST_BUILDER_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+namespace credentio {
+
+class TestGifBuilder {
+ public:
+  // Adds a GIF header block.
+  void AddGifHeader();
+
+  // Adds a GIF logical descriptor block.
+  void AddLogicalDescriptor(bool has_color_table);
+
+  // Adds a GIF image descriptor block.
+  void AddImageDescriptor(bool has_color_table);
+
+  // Adds a generic block to the GIF.
+  void AddBlock(uint8_t introducer, std::optional<uint8_t> type = std::nullopt,
+                std::optional<std::string> info = std::nullopt,
+                std::optional<std::string> data = std::nullopt);
+
+  // Returns the built GIF.
+  std::string GetAsset();
+
+ private:
+  std::string gif_data_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_TEST_BUILDER_H_
diff --git a/formats/id3/BUILD b/formats/id3/BUILD
new file mode 100644
index 0000000..90a715f
--- /dev/null
+++ b/formats/id3/BUILD
@@ -0,0 +1,158 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "id3",
+    srcs = ["id3.cc"],
+    hdrs = ["id3.h"],
+    deps = [
+        ":constants",
+        "//utils:riegeli",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "id3_test",
+    srcs = ["id3_test.cc"],
+    deps = [
+        ":id3",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "reader",
+    srcs = ["reader.cc"],
+    hdrs = ["reader.h"],
+    deps = [
+        ":id3",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = ["@abseil-cpp//absl/strings:string_view"],
+)
+
+cc_test(
+    name = "reader_test",
+    srcs = ["reader_test.cc"],
+    deps = [
+        ":id3",
+        ":reader",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":constants",
+        ":id3",
+        ":reader",
+        "//constants:labels",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    deps = [
+        ":extractor",
+        "//formats:byte_range",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        ":constants",
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/id3/assessor.cc b/formats/id3/assessor.cc
new file mode 100644
index 0000000..ded3ee3
--- /dev/null
+++ b/formats/id3/assessor.cc
@@ -0,0 +1,89 @@
+// 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 "formats/id3/assessor.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/id3/constants.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+namespace {
+
+// The first 5 bytes of an ID3 v2.3 tag header in uint64_t format.
+constexpr uint64_t kId3v23Header =
+    ((uint64_t)kId3FileIdentifierAndVersionV23[0] << 32) |
+    ((uint64_t)kId3FileIdentifierAndVersionV23[1] << 24) |
+    ((uint64_t)kId3FileIdentifierAndVersionV23[2] << 16) |
+    ((uint64_t)kId3FileIdentifierAndVersionV23[3] << 8) |
+    ((uint64_t)kId3FileIdentifierAndVersionV23[4]);
+
+// The first 5 bytes of an ID3 v2.4 tag header in uint64_t format.
+constexpr uint64_t kId3v24Header =
+    ((uint64_t)kId3FileIdentifierAndVersionV24[0] << 32) |
+    ((uint64_t)kId3FileIdentifierAndVersionV24[1] << 24) |
+    ((uint64_t)kId3FileIdentifierAndVersionV24[2] << 16) |
+    ((uint64_t)kId3FileIdentifierAndVersionV24[3] << 8) |
+    ((uint64_t)kId3FileIdentifierAndVersionV24[4]);
+
+// The first 2 bytes of an MPEG Version 2 Layer 3 frame header, with CRC
+// protection enabled.
+constexpr uint64_t kMp3MpegFrameHeader = 0xFFF3ULL;
+
+// The FLAC stream marker "fLaC".
+constexpr uint32_t kFlacMarker = 0x664C6143;
+
+// Returns true if the `header` starts with the ID3 v2.3 or v2.4 identifier.
+//
+// For a MP3 file that contains ID3 tags, the ID3 tags can be prepended to the
+// audio data.
+bool StartsWithId3Header(uint64_t header) {
+  // Compare the first 5 bytes.
+  return (header >> 24) == kId3v23Header || (header >> 24) == kId3v24Header;
+}
+
+// Returns true if the `header` starts with a MPEG frame, indicating a MP3 file.
+bool StartsWithMp3MpegFrameHeader(uint64_t header) {
+  // Compare the first 15 bits, ignoring the protection bit.
+  return (header >> 49) == (kMp3MpegFrameHeader >> 1);
+}
+
+// Returns true if the `header` starts with FLAC marker.
+bool StartsWithFlacMarker(uint64_t header) {
+  // Compare the first 4 bytes.
+  return (header >> 32) == kFlacMarker;
+}
+}  // namespace
+
+absl::StatusOr<bool> Id3Assessor::IsSupported(riegeli::Reader& input) const {
+  int64_t starting_position = input.pos();
+
+  uint64_t header;
+  if (!riegeli::ReadBigEndian<uint64_t>(input, header)) {
+    return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof"));
+  }
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to starting position"));
+  }
+
+  return StartsWithId3Header(header) || StartsWithMp3MpegFrameHeader(header) ||
+         StartsWithFlacMarker(header);
+}
+}  // namespace credentio
diff --git a/formats/id3/assessor.h b/formats/id3/assessor.h
new file mode 100644
index 0000000..a79d30e
--- /dev/null
+++ b/formats/id3/assessor.h
@@ -0,0 +1,34 @@
+// 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_FORMATS_ID3_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Assessor for the ID3-compatible audio format.
+class Id3Assessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ID3_ASSESSOR_H_
diff --git a/formats/id3/assessor_test.cc b/formats/id3/assessor_test.cc
new file mode 100644
index 0000000..9c6c517
--- /dev/null
+++ b/formats/id3/assessor_test.cc
@@ -0,0 +1,92 @@
+// 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 "formats/id3/assessor.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(IsSupportedTest, TooFewBytes) {
+  std::string audio = "\xFF";
+  riegeli::StringReader<> input(audio);
+
+  EXPECT_THAT(
+      Id3Assessor().IsSupported(input),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof")));
+}
+
+TEST(IsSupportedTest, FalseForInvalidBeginning) {
+  std::string audio = "this_is_not_a_mp3";
+  riegeli::StringReader<> input(audio);
+
+  EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, ValidId3v23Header) {
+  std::string audio("ID3\x03\x00\xFF\xFF\xFF", 8);
+  riegeli::StringReader<> input(audio);
+
+  EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidId3v24Header) {
+  std::string audio("ID3\x04\x00\xFF\xFF\xFF", 8);
+  riegeli::StringReader<> input(audio);
+
+  EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidMp3MpegFrameHeader) {
+  std::string audio = "\xFF\xF2\xFF\xFF\xFF\xFF\xFF\xFF";
+  riegeli::StringReader<> input(audio);
+
+  EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidFlacHeader) {
+  std::string audio("fLaC\x00\x00\x00\x22", 8);
+  riegeli::StringReader<> input(audio);
+
+  EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidStartingBytesAtOffset2) {
+  std::string audio = "ab\xFF\xF3\xFF\xFF\xFF\xFF\xFF\xFF";
+  riegeli::StringReader<> input(audio);
+
+  // Invalid at 0
+  EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(Id3Assessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/id3/constants.h b/formats/id3/constants.h
new file mode 100644
index 0000000..0178f33
--- /dev/null
+++ b/formats/id3/constants.h
@@ -0,0 +1,39 @@
+// 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_FORMATS_ID3_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_CONSTANTS_H_
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// The first five bytes of an ID3 v2.3 tag header. See
+// https://id3.org/id3v2.3.0#ID3v2_header
+constexpr char kId3FileIdentifierAndVersionV23[] = {'I', 'D', '3', '\x03',
+                                                    '\x00'};
+// The first five bytes of an ID3 v2.4 tag header. See
+// https://id3.org/id3v2.4.0-structure
+constexpr char kId3FileIdentifierAndVersionV24[] = {'I', 'D', '3', '\x04',
+                                                    '\x00'};
+
+// A deprecated MIME type still used by c2pa-rs SDK.
+constexpr absl::string_view kGeobFrameMimeTypeDeprecated =
+    "application/x-c2pa-manifest-store";
+constexpr absl::string_view kGeobFrameMimeType = "application/c2pa";
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ID3_CONSTANTS_H_
diff --git a/formats/id3/extractor.cc b/formats/id3/extractor.cc
new file mode 100644
index 0000000..8273cb2
--- /dev/null
+++ b/formats/id3/extractor.cc
@@ -0,0 +1,116 @@
+// 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 "formats/id3/extractor.h"
+
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/byte_range.h"
+#include "formats/id3/constants.h"
+#include "formats/id3/id3.h"
+#include "formats/id3/reader.h"
+#include "jumbf/utils.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+struct C2paManifestStore {
+  ByteRange location;
+  std::string content;
+};
+
+bool ContainsC2paManifestStore(const GeobFrame& geob) {
+  return geob.mime_type == kGeobFrameMimeType ||
+         geob.mime_type == kGeobFrameMimeTypeDeprecated;
+}
+
+absl::StatusOr<C2paManifestStore> ExtractManifestStoreInternal(
+    riegeli::Reader& input) {
+  C2paManifestStore manifest_store;
+
+  auto status = IterateOverId3Frames(
+      input, [&](const Id3Frame& frame) -> absl::StatusOr<bool> {
+        if (frame.id == "GEOB") {
+          ABSL_ASSIGN_OR_RETURN(const GeobFrame geob,
+                                GeobFrame::Parse(input, frame.data_offset(),
+                                                 frame.data_length()));
+          if (ContainsC2paManifestStore(geob)) {
+            if (!manifest_store.content.empty()) {
+              return absl::NotFoundError("Multiple manifest stores found");
+            }
+            manifest_store.location = {
+                .offset = frame.data_offset() + frame.data_length() -
+                          geob.encapsulated_object.size(),
+                .length = geob.encapsulated_object.size(),
+            };
+            manifest_store.content = std::move(geob.encapsulated_object);
+          }
+        }
+        return true;
+      });
+
+  if (absl::IsNotFound(status.status())) {
+    if (status.status().message() != "Multiple manifest stores found") {
+      return absl::NotFoundError("No manifest store found");
+    }
+    return status.status();
+  }
+  if (!status.ok()) {
+    return status.status();
+  }
+
+  if (manifest_store.content.empty()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return manifest_store;
+}
+}  // namespace
+
+absl::StatusOr<std::string> Id3Extractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  ABSL_ASSIGN_OR_RETURN(auto manifest_store,
+                        ExtractManifestStoreInternal(input));
+  return manifest_store.content;
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+Id3Extractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                           ExtractOptions options) const {
+  auto manifest_store = ExtractManifestStoreInternal(input);
+
+  if (!manifest_store.ok()) {
+    if (options.requires_c2pa) {
+      return manifest_store.status();
+    }
+    return std::nullopt;
+  }
+  return manifest_store->location;
+}
+
+bool Id3Extractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+}  // namespace credentio
diff --git a/formats/id3/extractor.h b/formats/id3/extractor.h
new file mode 100644
index 0000000..f90aba5
--- /dev/null
+++ b/formats/id3/extractor.h
@@ -0,0 +1,55 @@
+// 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_FORMATS_ID3_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_byte_info.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Implements the C2PA Manifest Store extractor for ID3v2 compatible files as
+// described in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_id3
+class Id3Extractor : public FormatExtractor {
+ public:
+  Id3Extractor() = default;
+  ~Id3Extractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError("ID3 tag does not have a concept of boxes");
+  }
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ID3_EXTRACTOR_H_
diff --git a/formats/id3/extractor_test.cc b/formats/id3/extractor_test.cc
new file mode 100644
index 0000000..3331874
--- /dev/null
+++ b/formats/id3/extractor_test.cc
@@ -0,0 +1,186 @@
+// 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 "formats/id3/extractor.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "formats/byte_range.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::AllOf;
+using ::testing::Eq;
+using ::testing::Field;
+using ::testing::HasSubstr;
+using ::testing::Optional;
+
+#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
+
+constexpr unsigned char kId3TagWithC2paManifestStore[] = {
+    'I', 'D', '3',           // ID3 file identifier
+    0x03, 0x00,              // Version 2.3
+    0x00,                    // Flags
+    0x00, 0x00, 0x00, 0x3E,  // Tag size = 62 (52 + 10)
+    'G', 'E', 'O', 'B',      // Frame ID
+    0x00, 0x00, 0x00, 0x34,  // Frame Size = 52
+    0x00, 0x00,              // Frame flags
+    0x01,                    // Text encoding [UTF-16]
+    // MIME type
+    'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'c', '2', 'p',
+    'a', 0x00,
+    // File name [UTF-16LE]
+    0xFF, 0xFE, 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00,
+    // Content description [UTF-16LE]
+    0xFF, 0xFE, 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00,
+    // Encapsulated object
+    'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e'};
+
+constexpr unsigned char kId3TagWithMultipleC2paManifestStores[] = {
+    'I', 'D', '3',           // ID3 file identifier
+    0x03, 0x00,              // Version 2.3
+    0x00,                    // Flags
+    0x00, 0x00, 0x00, 0x7C,  // Tag size = 124 (52 + 10 + 52 + 10)
+    // Frame 1
+    'G', 'E', 'O', 'B',      // Frame ID
+    0x00, 0x00, 0x00, 0x34,  // Frame Size = 52
+    0x00, 0x00,              // Frame flags
+    0x01,                    // Text encoding [UTF-16]
+    // MIME type
+    'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'c', '2', 'p',
+    'a', 0x00,
+    // File name [UTF-16LE]
+    0xFF, 0xFE, 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00,
+    // Content description [UTF-16LE]
+    0xFF, 0xFE, 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00,
+    // Encapsulated object
+    'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e',
+    // Frame 2
+    'G', 'E', 'O', 'B',      // Frame ID
+    0x00, 0x00, 0x00, 0x34,  // Frame Size = 52
+    0x00, 0x00,              // Frame flags
+    0x01,                    // Text encoding [UTF-16]
+    // MIME type
+    'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'c', '2', 'p',
+    'a', 0x00,
+    // File name [UTF-16LE]
+    0xFF, 0xFE, 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00,
+    // Content description [UTF-16LE]
+    0xFF, 0xFE, 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00,
+    // Encapsulated object
+    'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e'};
+
+constexpr unsigned char kId3TagWithC2paManifestStoreDeprecatedMimeType[] = {
+    'I', 'D', '3',           // ID3 file identifier
+    0x03, 0x00,              // Version 2.3
+    0x00,                    // Flags
+    0x00, 0x00, 0x00, 0x4F,  // Tag size = 79 (69 + 10)
+    'G', 'E', 'O', 'B',      // Frame ID
+    0x00, 0x00, 0x00, 0x45,  // Frame Size = 69
+    0x00, 0x00,              // Frame flags
+    0x01,                    // Text encoding [UTF-16]
+    // MIME type
+    'a', 'p', 'p', 'l', 'i', 'c', 'a', 't', 'i', 'o', 'n', '/', 'x', '-', 'c',
+    '2', 'p', 'a', '-', 'm', 'a', 'n', 'i', 'f', 'e', 's', 't', '-', 's', 't',
+    'o', 'r', 'e', 0x00,
+    // File name [UTF-16LE]
+    0xFF, 0xFE, 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00,
+    // Content description [UTF-16LE]
+    0xFF, 0xFE, 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00,
+    // Encapsulated object
+    'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e'};
+
+TEST(Id3ExtractorTest,
+     ExtractManifestStoreFromGeobFrameWithDeprecatedMimeType) {
+  std::string mp3_file_contents =
+      std::string(reinterpret_cast<const char*>(
+                      kId3TagWithC2paManifestStoreDeprecatedMimeType),
+                  sizeof(kId3TagWithC2paManifestStoreDeprecatedMimeType));
+  riegeli::StringReader<> input(mp3_file_contents);
+  Id3Extractor extractor;
+  auto result = extractor.ExtractManifestStore(input);
+  ASSERT_THAT(result, IsOk());
+  EXPECT_THAT(*result, HasSubstr("manifest store"));
+}
+
+TEST(Id3ExtractorTest, ExtractManifestStore) {
+  std::string mp3_file_contents =
+      std::string(reinterpret_cast<const char*>(kId3TagWithC2paManifestStore),
+                  sizeof(kId3TagWithC2paManifestStore));
+  riegeli::StringReader<> input(mp3_file_contents);
+  Id3Extractor extractor;
+  auto result = extractor.ExtractManifestStore(input);
+  ASSERT_THAT(result, IsOk());
+  EXPECT_THAT(*result, HasSubstr("manifest store"));
+}
+
+TEST(Id3ExtractorTest,
+     ExtractManifestStoreThrowsErrorIfMultipleC2paManifestStoresFound) {
+  std::string mp3_file_contents = std::string(
+      reinterpret_cast<const char*>(kId3TagWithMultipleC2paManifestStores),
+      sizeof(kId3TagWithMultipleC2paManifestStores));
+  riegeli::StringReader<> input(mp3_file_contents);
+  Id3Extractor extractor;
+
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("Multiple manifest stores found")));
+}
+
+TEST(Id3ExtractorTest, ExtractManifestStoreLocation) {
+  std::string mp3_file_contents =
+      std::string(reinterpret_cast<const char*>(kId3TagWithC2paManifestStore),
+                  sizeof(kId3TagWithC2paManifestStore));
+  riegeli::StringReader<> input(mp3_file_contents);
+  Id3Extractor extractor;
+  ASSERT_OK_AND_ASSIGN(
+      auto manifest_store_location,
+      extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}));
+
+  EXPECT_THAT(manifest_store_location,
+              Optional(AllOf(Field(&ByteRange::offset, Eq(58)),
+                             Field(&ByteRange::length, Eq(14)))));
+}
+
+TEST(Id3ExtractorTest, ExtractBoxesUnimplemented) {
+  riegeli::StringReader<> input("test_content");
+  Id3Extractor extractor;
+  EXPECT_THAT(extractor.ExtractBoxes(input, {.requires_c2pa = true}),
+              StatusIs(absl::StatusCode::kUnimplemented));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/id3/format.cc b/formats/id3/format.cc
new file mode 100644
index 0000000..54a1db1
--- /dev/null
+++ b/formats/id3/format.cc
@@ -0,0 +1,35 @@
+// 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 "formats/id3/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+#include "formats/id3/assessor.h"
+#include "formats/id3/extractor.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateId3Format() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<Id3Assessor>(),
+      .extractor = std::make_unique<Id3Extractor>(),
+      .mime_types = {"audio/mpeg", "audio/flac"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/id3/format.h b/formats/id3/format.h
new file mode 100644
index 0000000..6f95556
--- /dev/null
+++ b/formats/id3/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_ID3_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateId3Format();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ID3_FORMAT_H_
diff --git a/formats/id3/id3.cc b/formats/id3/id3.cc
new file mode 100644
index 0000000..7cf2cee
--- /dev/null
+++ b/formats/id3/id3.cc
@@ -0,0 +1,338 @@
+// 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 "formats/id3/id3.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "formats/id3/constants.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+#include "utils/riegeli.h"
+
+namespace credentio {
+namespace {
+
+constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;  // 10 MiB
+
+constexpr int kUtf16BomSize = 2;
+
+enum class TextEncoding : uint8_t {
+  // Text terminated with 0x00.
+  kIso8859_1 = 0x00,
+
+  // UTF-16 encoded Unicode with BOM.
+  // Text terminated with 0x00 0x00.
+  kUnicode = 0x01,
+
+  // UTF-16BE encoded Unicode without BOM.
+  // Text terminated with 0x00 0x00.
+  kUtf16Be = 0x02,
+
+  // UTF-8 encoded Unicode.
+  // Text terminated with 0x00.
+  kUtf8 = 0x03,
+};
+
+absl::StatusOr<TextEncoding> ReadTextEncoding(riegeli::Reader& input) {
+  uint8_t text_encoding_byte;
+  if (!input.ReadByte(text_encoding_byte)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read text encoding byte"));
+  }
+  if (text_encoding_byte <= 0x03) {
+    return static_cast<TextEncoding>(text_encoding_byte);
+  }
+  return absl::FailedPreconditionError(
+      absl::StrCat("Invalid text encoding value: ", text_encoding_byte));
+}
+
+absl::StatusOr<std::string> ReadUtf8Text(riegeli::Reader& input,
+                                         uint64_t max_length) {
+  std::string output;
+  if (!ReadNullTerminatedString(input, max_length, output)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read utf-8 text"));
+  }
+  return output;
+}
+
+absl::Status SkipTerminatedUnicodeText(riegeli::Reader& input,
+                                       uint64_t max_length, bool read_bom) {
+  uint64_t read_size = 0;
+  if (read_bom) {
+    read_size = kUtf16BomSize;
+    if (!input.Skip(kUtf16BomSize)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to skip UTF-16 BOM"));
+    }
+  }
+
+  uint16_t wide_char;
+  while (read_size < max_length) {
+    read_size += 2;
+    if (!riegeli::ReadBigEndian<uint16_t>(input, wide_char)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read UTF-16 Character"));
+    }
+    if (wide_char == 0) {
+      break;
+    }
+  }
+  return absl::OkStatus();
+}
+
+absl::Status SkipTerminatedText(riegeli::Reader& input, uint64_t max_length,
+                                TextEncoding text_encoding) {
+  switch (text_encoding) {
+    case TextEncoding::kIso8859_1:
+      // ISO-8859-1 uses the same encoding as UTF-8 for the ASCII subset.
+      return ReadUtf8Text(input, max_length).status();
+    case TextEncoding::kUnicode:
+      return SkipTerminatedUnicodeText(input, max_length, /*read_bom=*/true);
+    case TextEncoding::kUtf16Be:
+      return SkipTerminatedUnicodeText(input, max_length, /*read_bom=*/false);
+    case TextEncoding::kUtf8:
+      return ReadUtf8Text(input, max_length).status();
+    default:
+      return absl::UnimplementedError(
+          absl::StrCat("Unsupported text encoding: ", text_encoding));
+  }
+}
+
+// Computes the ID3v2.3.0 tag size from a 4-byte syncsafe integer string.
+// IDv2.3.0 uses syncsafe integer strings for the sizes.
+absl::StatusOr<uint32_t> GetSizeFromSyncsafeBytes(
+    absl::string_view syncsafe_bytes) {
+  if (syncsafe_bytes.length() != 4) {
+    return absl::InvalidArgumentError("Size should be 4 bytes");
+  }
+  // Cast the characters to unsigned 8-bit integers (bytes)
+  uint8_t byte1 = static_cast<uint8_t>(syncsafe_bytes[0]) & 0b01111111;
+  uint8_t byte2 = static_cast<uint8_t>(syncsafe_bytes[1]) & 0b01111111;
+  uint8_t byte3 = static_cast<uint8_t>(syncsafe_bytes[2]) & 0b01111111;
+  uint8_t byte4 = static_cast<uint8_t>(syncsafe_bytes[3]) & 0b01111111;
+
+  // Combine the 7-bit chunks from each byte
+  uint32_t size = (byte1 << 21) | (byte2 << 14) | (byte3 << 7) | byte4;
+
+  return size;
+}
+}  // namespace
+
+absl::StatusOr<GeobFrame> GeobFrame::Parse(riegeli::Reader& input,
+                                           uint64_t data_offset,
+                                           uint64_t data_length) {
+  if (!input.Seek(data_offset)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to start of data"));
+  }
+  GeobFrame frame;
+  const uint64_t end_offset = data_offset + data_length;
+
+  ABSL_ASSIGN_OR_RETURN(auto text_encoding, ReadTextEncoding(input));
+  if (input.pos() > end_offset) {
+    return absl::DataLossError(
+        "Data structure exceeded specified offset boundary limit.");
+  }
+
+  // MIME type is always ISO-8859-1 encoded.
+  ABSL_ASSIGN_OR_RETURN(
+      frame.mime_type,
+      ReadUtf8Text(input, /*max_length=*/end_offset - input.pos()));
+  if (input.pos() > end_offset) {
+    return absl::DataLossError(
+        "Data structure exceeded specified offset boundary limit.");
+  }
+
+  // Skip filename.
+  ABSL_RETURN_IF_ERROR(SkipTerminatedText(
+      input, /*max_length=*/end_offset - input.pos(), text_encoding));
+  if (input.pos() > end_offset) {
+    return absl::DataLossError(
+        "Data structure exceeded specified offset boundary limit.");
+  }
+
+  // Skip content description.
+  ABSL_RETURN_IF_ERROR(SkipTerminatedText(
+      input, /*max_length=*/end_offset - input.pos(), text_encoding));
+  if (input.pos() > end_offset) {
+    return absl::DataLossError(
+        "Data structure exceeded specified offset boundary limit.");
+  }
+  // Read the encapsulated object as raw bytes.
+  uint64_t payload_size = end_offset - input.pos();
+  if (payload_size > kMaxPayloadSize) {
+    return absl::InvalidArgumentError(
+        "Encapsulated object size exceeds maximum payload size");
+  }
+  if (!input.Read(payload_size, frame.encapsulated_object)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read encapsulated object"));
+  }
+
+  return frame;
+}
+
+absl::StatusOr<Id3Header> Id3Header::Parse(riegeli::Reader& input) {
+  Id3Header id3_header;
+  std::string identifier_and_version;
+  if (!input.Read(5, identifier_and_version)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read id and version"));
+  }
+  if (identifier_and_version ==
+      std::string(kId3FileIdentifierAndVersionV23, 5)) {
+    id3_header.major_version = 3;
+  } else if (identifier_and_version ==
+             std::string(kId3FileIdentifierAndVersionV24, 5)) {
+    id3_header.major_version = 4;
+  } else {
+    return absl::NotFoundError("ID3v2 tag not found");
+  }
+  // Read the flag byte
+  uint8_t flag_byte;
+  if (!input.ReadByte(flag_byte)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read flag byte"));
+  }
+  // Check that the flag is valid
+  // Only bits 5-7 should be set.
+  if ((flag_byte & 0b00011111) != 0) {
+    return absl::InvalidArgumentError("invalid ID3v2 flag byte");
+  }
+  // Check bit 7 for the presence of the unsynchronisation bit
+  id3_header.use_unsynchronisation = (flag_byte & 0b10000000) != 0;
+  // Check bit 6 for the presence of the extended header bit
+  id3_header.has_extended_header = (flag_byte & 0b01000000) != 0;
+  // Check bit 5 for the presence of the experimental indicator bit
+  id3_header.is_experimental = (flag_byte & 0b00100000) != 0;
+
+  std::string syncsafe_bytes;
+  if (!input.Read(4, syncsafe_bytes)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read syncsafe bytes"));
+  }
+  ABSL_ASSIGN_OR_RETURN(uint32_t size,
+                        GetSizeFromSyncsafeBytes(syncsafe_bytes));
+  if (size == 0) {
+    return absl::InvalidArgumentError("no frame inside the ID3v2 tag");
+  }
+  // `size` is the size of the tag, not including the header but including the
+  // extended header if present.
+  id3_header.tag_size = size;
+
+  // Process the extended header if present.
+  if (id3_header.has_extended_header) {
+    // Read the size of the extended header, excluding the size field itself.
+    // Header size field is 4 bytes.
+    // The first 3 bytes should be zero.
+    // The last byte should be 0x0A (ten) or 0x06 (six).
+    if (!riegeli::ReadBigEndian<uint32_t>(input,
+                                          id3_header.extended_header_size)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed extracting the extended header size"));
+    }
+    if (id3_header.extended_header_size != 10 &&
+        id3_header.extended_header_size != 6) {
+      return absl::InvalidArgumentError("invalid extended header size value");
+    }
+    if (size == id3_header.extended_header_size + 4) {
+      // If the size of the extended header is the same as the size of the tag,
+      // it means that there are no frames inside the tag.
+      return absl::InvalidArgumentError("no frame inside the ID3v2 tag");
+    }
+    if (!input.Read(2, id3_header.extended_flags)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed extracting the extended flags"));
+    }
+    if (!riegeli::ReadBigEndian<uint32_t>(input, id3_header.padding_size)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed extracting the padding size"));
+    }
+    if (id3_header.extended_header_size == 10) {
+      if (!input.Read(4, id3_header.total_frame_crc)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed extracting the total frame crc"));
+      }
+    }
+  }
+  return id3_header;
+}
+
+absl::Cord Id3Header::ToCord() const {
+  absl::Cord output;
+  output.Append(absl::string_view("ID3", 3));
+  if (major_version == 4) {
+    output.Append(absl::string_view("\x04\x00", 2));  // Version 2.4
+  } else {
+    output.Append(absl::string_view("\x03\x00", 2));  // Version 2.3
+  }
+
+  // Flags
+  char flags = 0;
+  if (use_unsynchronisation) {
+    flags |= 1 << 7;
+  }
+  if (has_extended_header) {
+    flags |= 1 << 6;
+  }
+  if (is_experimental) {
+    flags |= 1 << 5;
+  }
+  output.Append(absl::string_view(&flags, 1));
+
+  char tag_size_bytes[4];
+  tag_size_bytes[0] = (tag_size >> 21) & 0x7F;
+  tag_size_bytes[1] = (tag_size >> 14) & 0x7F;
+  tag_size_bytes[2] = (tag_size >> 7) & 0x7F;
+  tag_size_bytes[3] = tag_size & 0x7F;
+  output.Append(absl::string_view(tag_size_bytes, 4));
+
+  if (has_extended_header) {
+    // Append the size of the extended header (4 bytes, big-endian).
+    char extended_header_size_bytes[4];
+    extended_header_size_bytes[0] = (extended_header_size >> 24) & 0xFF;
+    extended_header_size_bytes[1] = (extended_header_size >> 16) & 0xFF;
+    extended_header_size_bytes[2] = (extended_header_size >> 8) & 0xFF;
+    extended_header_size_bytes[3] = extended_header_size & 0xFF;
+    output.Append(absl::string_view(extended_header_size_bytes, 4));
+    // Append the extended flags (2 bytes).
+    output.Append(extended_flags);
+    // Append the padding size (4 bytes, big-endian).
+    char padding_size_bytes[4];
+    padding_size_bytes[0] = (padding_size >> 24) & 0xFF;
+    padding_size_bytes[1] = (padding_size >> 16) & 0xFF;
+    padding_size_bytes[2] = (padding_size >> 8) & 0xFF;
+    padding_size_bytes[3] = padding_size & 0xFF;
+    output.Append(absl::string_view(padding_size_bytes, 4));
+
+    if (extended_header_size == 10) {
+      // Append the total frame CRC (4 bytes).
+      output.Append(total_frame_crc);
+    }
+  }
+
+  return output;
+}
+
+}  // namespace credentio
diff --git a/formats/id3/id3.h b/formats/id3/id3.h
new file mode 100644
index 0000000..1b96a07
--- /dev/null
+++ b/formats/id3/id3.h
@@ -0,0 +1,96 @@
+// 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_FORMATS_ID3_ID3_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_ID3_H_
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Represents the header of an ID3 tag.
+struct Id3Header {
+  // Whether or not unsynchronisation is used.
+  bool use_unsynchronisation;
+  // Whether or not the extended header is present.
+  bool has_extended_header;
+  // Whether or not the experimental indicator is set.
+  bool is_experimental;
+  // The size of the ID3 tag, excluding the size of the header but including the
+  // size of the extended header if present.
+  uint32_t tag_size;
+
+  // The size of the extended header, excluding the size field itself. Only
+  // relevant if `has_extended_header` is true.
+  uint32_t extended_header_size;
+  // Extended flags in the extended header. Only relevant if
+  // `has_extended_header` is true.
+  std::string extended_flags;
+  // The size of the padding added to the end of the ID3 tag. Only relevant if
+  // `has_extended_header` is true.
+  uint32_t padding_size;
+  // The total frame CRC in the extended header. Only relevant if
+  // `has_extended_header` is true.
+  std::string total_frame_crc;
+
+  // The major version of the tag (e.g., 3 for ID3v2.3, 4 for ID3v2.4).
+  uint8_t major_version = 3;
+
+  // Parses the ID3 header from the given input.
+  static absl::StatusOr<Id3Header> Parse(riegeli::Reader& input);
+
+  // Returns the ID3 header encoded as an `absl::Cord`.
+  absl::Cord ToCord() const;
+};
+
+// Represents a raw ID3 frame read from an MP3 file.
+struct Id3Frame {
+  // The offset of the ID3 frame from the beginning of the MP3 file.
+  uint64_t offset;
+  // The size of the ID3 frame, including the 10-byte header.
+  uint64_t length;
+  // The 4-character ID of the frame (e.g., "GEOB").
+  std::string id;
+
+  // Returns the offset of the data section within the frame, relative to
+  // the beginning of the MP3 file. The data section starts immediately after
+  // the 10-byte ID3 frame header.
+  uint64_t data_offset() const { return offset + 10; }
+  // Returns the length of the data section within the frame.
+  uint64_t data_length() const { return length - 10; }
+
+  bool operator==(const Id3Frame& other) const {
+    return offset == other.offset && length == other.length && id == other.id;
+  }
+};
+
+struct GeobFrame {
+  std::string mime_type;
+  std::string encapsulated_object;
+
+  // Parses a GEOB frame from the given input.
+  static absl::StatusOr<GeobFrame> Parse(riegeli::Reader& input,
+                                         uint64_t data_offset,
+                                         uint64_t data_length);
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ID3_ID3_H_
diff --git a/formats/id3/id3_test.cc b/formats/id3/id3_test.cc
new file mode 100644
index 0000000..dacab97
--- /dev/null
+++ b/formats/id3/id3_test.cc
@@ -0,0 +1,475 @@
+// 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 "formats/id3/id3.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(GeobFrameTest, ParseIso8859_1) {
+  const char geob_frame[] = {
+      0x00,  // Text encoding [ISO-8859-1]
+      't',  'e', 'x', 't', '/',  'p', 'l', 'a', 'i', 'n',
+      0x00,                           // MIME type
+      'f',  'i', 'l', 'e', 0x00,      // Filename
+      'd',  'e', 's', 'c', 0x00,      // Content description
+      'o',  'b', 'j', 'e', 'c',  't'  // Encapsulated object
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0,
+                                  /*data_length=*/geob_frame_contents.size());
+  ASSERT_THAT(geob_or, IsOk());
+  GeobFrame geob = *geob_or;
+
+  EXPECT_EQ(geob.mime_type, "text/plain");
+  EXPECT_EQ(geob.encapsulated_object, "object");
+}
+
+TEST(GeobFrameTest, ParseUtf16LeBom) {
+  const char geob_frame[] = {
+      0x01,  // Text encoding [UTF-16]
+      't',    'e',    'x',  't',  '/', 'p', 'l', 'a', 'i', 'n',
+      0x00,                                 // MIME type
+      '\xff', '\xfe',                       // BOM
+      'f',    0x00,   0x00, 0x00,           // Filename
+      '\xff', '\xfe',                       // BOM
+      'd',    0x00,   0x00, 0x00,           // Content description
+      'o',    'b',    'j',  'e',  'c', 't'  // Encapsulated object
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0,
+                                  /*data_length=*/geob_frame_contents.size());
+  ASSERT_THAT(geob_or, IsOk());
+  GeobFrame geob = *geob_or;
+
+  EXPECT_EQ(geob.mime_type, "text/plain");
+  EXPECT_EQ(geob.encapsulated_object, "object");
+}
+
+TEST(GeobFrameTest, ParseUtf16BeBom) {
+  const char geob_frame[] = {
+      0x01,  // Text encoding [UTF-16]
+      't',    'e',    'x',  't',  '/', 'p', 'l', 'a', 'i', 'n',
+      0x00,                                 // MIME type
+      '\xfe', '\xff',                       // BOM
+      0x00,   'f',    0x00, 0x00,           // Filename
+      '\xfe', '\xff',                       // BOM
+      0x00,   'd',    0x00, 0x00,           // Content description
+      'o',    'b',    'j',  'e',  'c', 't'  // Encapsulated object
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0,
+                                  /*data_length=*/geob_frame_contents.size());
+  ASSERT_THAT(geob_or, IsOk());
+  GeobFrame geob = *geob_or;
+
+  EXPECT_EQ(geob.mime_type, "text/plain");
+  EXPECT_EQ(geob.encapsulated_object, "object");
+}
+
+TEST(GeobFrameTest, ParseUtf16BeNoBom) {
+  const char geob_frame[] = {
+      0x02,  // Text encoding [UTF-16BE]
+      't',  'e', 'x',  't',  '/', 'p', 'l', 'a', 'i', 'n',
+      0x00,                            // MIME type
+      0x00, 'f', 0x00, 0x00,           // Filename
+      0x00, 'd', 0x00, 0x00,           // Content description
+      'o',  'b', 'j',  'e',  'c', 't'  // Encapsulated object
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0,
+                                  /*data_length=*/geob_frame_contents.size());
+  ASSERT_THAT(geob_or, IsOk());
+  GeobFrame geob = *geob_or;
+
+  EXPECT_EQ(geob.mime_type, "text/plain");
+  EXPECT_EQ(geob.encapsulated_object, "object");
+}
+
+TEST(GeobFrameTest, ParseUtf8) {
+  const char geob_frame[] = {
+      0x03,  // Text encoding [UTF-8]
+      't',  'e', 'x', 't', '/',  'p', 'l', 'a', 'i', 'n',
+      0x00,                           // MIME type
+      'f',  'i', 'l', 'e', 0x00,      // Filename
+      'd',  'e', 's', 'c', 0x00,      // Content description
+      'o',  'b', 'j', 'e', 'c',  't'  // Encapsulated object
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0,
+                                  /*data_length=*/geob_frame_contents.size());
+  ASSERT_THAT(geob_or, IsOk());
+  GeobFrame geob = *geob_or;
+
+  EXPECT_EQ(geob.mime_type, "text/plain");
+  EXPECT_EQ(geob.encapsulated_object, "object");
+}
+
+TEST(GeobFrameTest, ParseInvalidTextEncodingFails) {
+  const char geob_frame[] = {
+      0x04,  // Invalid text encoding
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+
+  EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0,
+                               /*data_length=*/geob_frame_contents.size()),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       HasSubstr("Invalid text encoding value")));
+}
+
+TEST(GeobFrameTest, ParseZeroLengthExceedingsBoundsFails) {
+  const char geob_frame[] = {
+      0x00,  // Text encoding [ISO-8859-1]
+      't',  'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n',
+      0x00,  // MIME type
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+
+  EXPECT_THAT(
+      GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/0),
+      StatusIs(
+          absl::StatusCode::kDataLoss,
+          HasSubstr(
+              "Data structure exceeded specified offset boundary limit.")));
+}
+
+TEST(GeobFrameTest, ParseMimeTypeNotTerminatedFails) {
+  // MIME type is not null-terminated within the frame data, but there is a
+  // null byte immediately after the frame data.
+  const char geob_frame[] = {
+      0x00,  // Text encoding [ISO-8859-1]
+      't',  'e', 'x', 't', '/',
+      'p',  'l', 'a', 'i', 'n',  // MIME type (10 bytes, no null)
+      0x00                       // Null byte outside the frame
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  // data_length is 11 (1 byte encoding + 10 bytes MIME type).
+  // The trailing null byte is at index 11, which is outside the frame.
+  EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/11),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       "Failed to read utf-8 text; at byte 1"));
+}
+
+TEST(GeobFrameTest, ParseFilenameNotTerminatedFails) {
+  // Filename is not null-terminated within the frame data, but there is a
+  // null byte immediately after the frame data.
+  const char geob_frame[] = {
+      0x00,  // Text encoding [ISO-8859-1]
+      't',  'e', 'x', 't', '/', 'p', 'l', 'a', 'i', 'n',
+      0x00,                 // MIME type
+      'f',  'i', 'l', 'e',  // Filename (4 bytes, no null)
+      0x00                  // Null byte outside the frame
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  // data_length is 1 + 11 + 4 = 16.
+  // The trailing null byte is at index 16, which is outside the frame.
+  EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/16),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       "Failed to read utf-8 text; at byte 12"));
+}
+
+TEST(GeobFrameTest, ParseDescriptionNotTerminatedFails) {
+  // Content description is not null-terminated within the frame data, but there
+  // is a null byte immediately after the frame data.
+  const char geob_frame[] = {
+      0x00,  // Text encoding [ISO-8859-1]
+      't',  'e', 'x', 't', '/',  'p', 'l', 'a', 'i', 'n',
+      0x00,                       // MIME type
+      'f',  'i', 'l', 'e', 0x00,  // Filename
+      'd',  'e', 's', 'c',        // Content description (4 bytes, no null)
+      0x00                        // Null byte outside the frame
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  // data_length is 1 + 11 + 5 + 4 = 21.
+  // The trailing null byte is at index 21, which is outside the frame.
+  EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/21),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       "Failed to read utf-8 text; at byte 17"));
+}
+
+TEST(GeobFrameTest, ParseDescriptionMissingFails) {
+  const char geob_frame[] = {
+      0x00,                        // Text encoding [ISO-8859-1]
+      'a',  0x00,                  // MIME type
+      'f',  'i',  'l', 'e', 0x00,  // Filename
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  // data_length is 8 (1 + 2 + 5).
+  // Description is missing.
+  EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/8),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       "Failed to read utf-8 text; at byte 8"));
+}
+
+TEST(GeobFrameTest, ParseFilenameMissingFails) {
+  const char geob_frame[] = {
+      0x00,  // Text encoding [ISO-8859-1]
+      'a',
+      0x00,  // MIME type
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  // data_length is 3 (1 + 2).
+  // Filename is missing.
+  EXPECT_THAT(GeobFrame::Parse(input, /*data_offset=*/0, /*data_length=*/3),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       "Failed to read utf-8 text; at byte 3"));
+}
+
+TEST(GeobFrameTest, ParseDescriptionEmptySucceeds) {
+  const char geob_frame[] = {
+      0x00,  // Text encoding [ISO-8859-1]
+      't',  'e', 'x', 't', '/',  'p', 'l', 'a', 'i', 'n',
+      0x00,                       // MIME type
+      'f',  'i', 'l', 'e', 0x00,  // Filename
+      0x00,                       // Content description (empty)
+  };
+  auto geob_frame_contents = std::string(geob_frame, sizeof(geob_frame));
+  riegeli::StringReader<> input(geob_frame_contents);
+  auto geob_or = GeobFrame::Parse(input, /*data_offset=*/0,
+                                  /*data_length=*/geob_frame_contents.size());
+  ASSERT_THAT(geob_or, IsOk());
+  GeobFrame geob = *geob_or;
+
+  EXPECT_EQ(geob.mime_type, "text/plain");
+  EXPECT_EQ(geob.encapsulated_object, "");
+}
+
+TEST(Id3HeaderTest, ParseId3HeaderNoExtendedHeader) {
+  const char id3_header[] = {
+      'I',  'D',  '3',        // ID3 identifier
+      0x03, 0x00,             // Version 2.3
+      0x00,                   // Flags
+      0x00, 0x00, 0x00, 127,  // Tag size
+  };
+  auto id3_header_contents = std::string(id3_header, sizeof(id3_header));
+  riegeli::StringReader<> input(id3_header_contents);
+  auto header_or = Id3Header::Parse(input);
+  ASSERT_THAT(header_or, IsOk());
+  Id3Header header = *header_or;
+
+  EXPECT_FALSE(header.use_unsynchronisation);
+  EXPECT_FALSE(header.has_extended_header);
+  EXPECT_FALSE(header.is_experimental);
+  EXPECT_EQ(header.tag_size, 127);
+}
+
+TEST(Id3HeaderTest, ParseId3HeaderWithValidExtendedHeader) {
+  const char id3_header[] = {
+      'I',  'D',  '3',         // ID3 identifier
+      0x03, 0x00,              // Version 2.3
+      0x40,                    // Flags (extended header)
+      0x00, 0x00, 0x00, 127,   // Tag size
+      0x00, 0x00, 0x00, 6,     // Extended header size
+      0x00, 0x00,              // Extended flags
+      0x00, 0x00, 0x00, 0x00,  // Padding size
+  };
+  auto id3_header_contents = std::string(id3_header, sizeof(id3_header));
+  riegeli::StringReader<> input(id3_header_contents);
+  auto header_or = Id3Header::Parse(input);
+  ASSERT_THAT(header_or, IsOk());
+  Id3Header header = *header_or;
+
+  EXPECT_FALSE(header.use_unsynchronisation);
+  EXPECT_TRUE(header.has_extended_header);
+  EXPECT_FALSE(header.is_experimental);
+  EXPECT_EQ(header.tag_size, 127);
+  EXPECT_EQ(header.extended_header_size, 6);
+  EXPECT_EQ(header.extended_flags, std::string("\0\0", 2));
+  EXPECT_EQ(header.padding_size, 0);
+}
+
+TEST(Id3HeaderTest, ParseId3HeaderInvalidIdentifierFails) {
+  const char id3_header[] = {
+      'I',  'D',  '4',        // Invalid ID3 identifier
+      0x03, 0x00,             // Version 2.3
+      0x00,                   // Flags
+      0x00, 0x00, 0x00, 127,  // Tag size
+  };
+  auto id3_header_contents = std::string(id3_header, sizeof(id3_header));
+  riegeli::StringReader<> input(id3_header_contents);
+  EXPECT_THAT(Id3Header::Parse(input),
+              StatusIs(absl::StatusCode::kNotFound, "ID3v2 tag not found"));
+}
+
+TEST(Id3HeaderTest, ParseId3HeaderInvalidVersionFails) {
+  const char id3_header[] = {
+      'I',  'D',  '3',        // ID3 identifier
+      0x03, 0x01,             // Invalid version
+      0x00,                   // Flags
+      0x00, 0x00, 0x00, 127,  // Tag size
+  };
+  auto id3_header_contents = std::string(id3_header, sizeof(id3_header));
+  riegeli::StringReader<> input(id3_header_contents);
+  EXPECT_THAT(
+      Id3Header::Parse(input),
+      StatusIs(absl::StatusCode::kNotFound, HasSubstr("ID3v2 tag not found")));
+}
+
+TEST(Id3HeaderTest, ParseId3HeaderInvalidFlagFails) {
+  const char id3_header[] = {
+      'I',  'D',  '3',        // ID3 identifier
+      0x03, 0x00,             // Version 2.3
+      0x01,                   // Invalid flags
+      0x00, 0x00, 0x00, 127,  // Tag size
+  };
+  auto id3_header_contents = std::string(id3_header, sizeof(id3_header));
+  riegeli::StringReader<> input(id3_header_contents);
+  EXPECT_THAT(
+      Id3Header::Parse(input),
+      StatusIs(absl::StatusCode::kInvalidArgument, "invalid ID3v2 flag byte"));
+}
+
+TEST(Id3HeaderTest, ParseId3HeaderZeroSizeFails) {
+  const char id3_header[] = {
+      'I',  'D',  '3',         // ID3 identifier
+      0x03, 0x00,              // Version 2.3
+      0x00,                    // Flags
+      0x00, 0x00, 0x00, 0x00,  // Tag size = 0
+  };
+  auto id3_header_contents = std::string(id3_header, sizeof(id3_header));
+  riegeli::StringReader<> input(id3_header_contents);
+  EXPECT_THAT(Id3Header::Parse(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "no frame inside the ID3v2 tag"));
+}
+
+TEST(Id3HeaderTest, ParseId3HeaderInvalidExtendedHeaderSizeFails) {
+  const char id3_header[] = {
+      'I',  'D',  '3',         // ID3 identifier
+      0x03, 0x00,              // Version 2.3
+      0x40,                    // Flags (extended header)
+      0x00, 0x00, 0x00, 127,   // Tag size
+      0x00, 0x00, 0x00, 7,     // Invalid extended header size
+      0x00, 0x00,              // Extended flags
+      0x00, 0x00, 0x00, 0x00,  // Padding size
+  };
+  auto id3_header_contents = std::string(id3_header, sizeof(id3_header));
+  riegeli::StringReader<> input(id3_header_contents);
+  EXPECT_THAT(Id3Header::Parse(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "invalid extended header size value"));
+}
+
+TEST(Id3HeaderTest, ParseId3HeaderNoFrameWithExtendedHeader) {
+  constexpr char id3_header[] = {
+      'I',  'D',  '3',         // ID3 identifier
+      0x03, 0x00,              // Version 2.3
+      0x40,                    // Flags (extended header)
+      0x00, 0x00, 0x00, 0x0e,  // Tag size
+      0x00, 0x00, 0x00, 0x0a,  // Extended header size = 10
+      0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+  auto id3_header_contents = std::string(id3_header, sizeof(id3_header));
+  riegeli::StringReader<> input(id3_header_contents);
+  EXPECT_THAT(Id3Header::Parse(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("no frame inside the ID3v2 tag")));
+}
+
+TEST(Id3HeaderTest, EncodeId3HeaderNoExtendedHeader) {
+  Id3Header header = {
+      .use_unsynchronisation = false,
+      .has_extended_header = false,
+      .is_experimental = false,
+      .tag_size = 127,
+  };
+
+  EXPECT_EQ(header.ToCord(),
+            absl::string_view("ID3\x03\x00\x00\x00\x00\x00\x7F", 10));
+}
+
+TEST(Id3HeaderTest, EncodeId3HeaderWithExtendedHeader6Bytes) {
+  Id3Header header = {
+      .use_unsynchronisation = false,
+      .has_extended_header = true,
+      .is_experimental = false,
+      .tag_size = 127,
+      .extended_header_size = 6,
+      .extended_flags = std::string("\0\0", 2),
+      .padding_size = 0,
+  };
+
+  EXPECT_EQ(
+      header.ToCord(),
+      absl::string_view(
+          "ID3\x03\x00\x40\x00\x00\x00\x7F\x00\x00\x00\x06\x00\x00\x00\x00"
+          "\x00\x00",
+          20));
+}
+
+TEST(Id3HeaderTest, EncodeId3HeaderWithExtendedHeader10Bytes) {
+  Id3Header header = {
+      .use_unsynchronisation = false,
+      .has_extended_header = true,
+      .is_experimental = false,
+      .tag_size = 127,
+      .extended_header_size = 10,
+      .extended_flags = std::string("\0\0", 2),
+      .padding_size = 0,
+      .total_frame_crc = "\x01\x02\x03\x04",
+  };
+
+  EXPECT_EQ(
+      header.ToCord(),
+      absl::string_view(
+          "ID3\x03\x00\x40\x00\x00\x00\x7F\x00\x00\x00\x0A\x00\x00\x00\x00"
+          "\x00\x00\x01\x02\x03\x04",
+          24));
+}
+
+TEST(Id3HeaderTest, EncodeId3HeaderWithAllFlags) {
+  Id3Header header = {
+      .use_unsynchronisation = true,
+      .has_extended_header = true,
+      .is_experimental = true,
+      .tag_size = 127,
+      .extended_header_size = 6,
+      .extended_flags = std::string("\0\0", 2),
+      .padding_size = 0,
+  };
+
+  EXPECT_EQ(
+      header.ToCord(),
+      absl::string_view(
+          "ID3\x03\x00\xE0\x00\x00\x00\x7F\x00\x00\x00\x06\x00\x00\x00\x00"
+          "\x00\x00",
+          20));
+}
+}  // namespace
+}  // namespace credentio
diff --git a/formats/id3/reader.cc b/formats/id3/reader.cc
new file mode 100644
index 0000000..d5e9d5a
--- /dev/null
+++ b/formats/id3/reader.cc
@@ -0,0 +1,126 @@
+// 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 "formats/id3/reader.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/id3/id3.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+
+constexpr uint32_t kId3TagHeaderSize = 10;
+constexpr uint32_t kId3FrameHeaderSize = 10;
+
+absl::StatusOr<Id3Header> IterateOverId3Frames(riegeli::Reader& reader,
+                                               Id3FrameProcessor processor) {
+  if (!reader.SupportsSize() || !reader.Size().has_value()) {
+    return absl::InvalidArgumentError("reader does not support size");
+  }
+
+  uint64_t beginning_offset = reader.pos();
+  ABSL_ASSIGN_OR_RETURN(Id3Header id3_header, Id3Header::Parse(reader));
+  uint64_t id3_tag_end_offset =
+      beginning_offset + kId3TagHeaderSize + id3_header.tag_size;
+
+  if (id3_tag_end_offset > reader.Size().value()) {
+    return absl::InvalidArgumentError("tag end offset exceeds input size");
+  }
+
+  uint64_t current_offset = reader.pos();
+
+  while (current_offset + kId3FrameHeaderSize < id3_tag_end_offset) {
+    // Peek the next frame ID to see if the ID is valid.
+    uint32_t frame_id;
+    if (!reader.Seek(current_offset)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek to current offset"));
+    }
+    if (!riegeli::ReadBigEndian<uint32_t>(reader, frame_id)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to read frame ID for peeking"));
+    }
+
+    // Check if the frame ID is all NULL bytes. This indicates the start of
+    // padding at the end of the ID3v2 tag.
+    if (frame_id == 0) {
+      break;
+    }
+
+    if (!reader.Seek(current_offset)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek back to current offset"));
+    }
+
+    Id3Frame frame;
+    frame.offset = current_offset;
+    // Read the ID3 frame header, which is 10 bytes.
+    if (!reader.Read(4, frame.id)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to read frame ID"));
+    }
+    uint32_t size;
+    if (!riegeli::ReadBigEndian<uint32_t>(reader, size)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to read frame size"));
+    }
+    if (id3_header.major_version == 4) {
+      if ((size & 0x80808080) != 0) {
+        return reader.StatusOrAnnotate(absl::DataLossError(
+            "Invalid synchsafe integer in ID3v2.4 frame size"));
+      }
+      size = ((size >> 24) & 0x7f) << 21 | ((size >> 16) & 0x7f) << 14 |
+             ((size >> 8) & 0x7f) << 7 | (size & 0x7f);
+    }
+    std::string flags;
+    if (!reader.Read(2, flags)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to read frame flags"));
+    }
+    const uint64_t remaining_tag_size =
+        id3_tag_end_offset - current_offset - kId3FrameHeaderSize;
+    if (size > remaining_tag_size) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("ID3 frame size exceeds remaining tag size"));
+    }
+
+    frame.length = static_cast<uint64_t>(size) + kId3FrameHeaderSize;
+
+    ABSL_ASSIGN_OR_RETURN(bool continue_iteration, processor(frame));
+    if (!continue_iteration) {
+      break;
+    }
+
+    // Seek to the end of the frame for the next iteration.
+    if (!reader.Seek(frame.offset + frame.length)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek to end of frame"));
+    }
+    current_offset = reader.pos();
+  }
+
+  return id3_header;
+}
+
+}  // namespace credentio
diff --git a/formats/id3/reader.h b/formats/id3/reader.h
new file mode 100644
index 0000000..68981e7
--- /dev/null
+++ b/formats/id3/reader.h
@@ -0,0 +1,42 @@
+// 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_FORMATS_ID3_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_READER_H_
+
+#include <cstdint>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/function_ref.h"
+#include "absl/status/statusor.h"
+#include "formats/id3/id3.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Callback function for IterateOverId3Frames.
+// Returns true to continue iteration, false to stop, or an error to abort.
+using Id3FrameProcessor =
+    absl::FunctionRef<absl::StatusOr<bool>(const Id3Frame&)>;
+
+// Iterates over all ID3 frames in the given reader, calling the given function
+// for each frame. Returns the ID3 header on success.
+// Returns NotFoundError if no ID3 tag is found.
+absl::StatusOr<Id3Header> IterateOverId3Frames(riegeli::Reader& reader,
+                                               Id3FrameProcessor processor);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ID3_READER_H_
diff --git a/formats/id3/reader_test.cc b/formats/id3/reader_test.cc
new file mode 100644
index 0000000..1e98844
--- /dev/null
+++ b/formats/id3/reader_test.cc
@@ -0,0 +1,180 @@
+// 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 "formats/id3/reader.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "formats/id3/id3.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(Id3ReaderTest, TagEndOffsetExceedsInputSize) {
+  constexpr char mp3_data[] = {'I',    'D',    '3',    '\x03', '\x00',
+                               '\x00', '\x7f', '\x7f', '\x7f', '\x7f'};
+  auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data));
+  riegeli::StringReader<> input(mp3_file_contents);
+
+  EXPECT_THAT(IterateOverId3Frames(input, [](const Id3Frame&) { return true; }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("tag end offset exceeds input size")));
+}
+
+class NonSizeSupportingStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return false; }
+};
+
+TEST(Id3ReaderTest, ReaderDoesNotSupportSize) {
+  NonSizeSupportingStringReader non_size_input("dummy data");
+
+  EXPECT_THAT(IterateOverId3Frames(non_size_input,
+                                   [](const Id3Frame&) { return true; }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("reader does not support size")));
+}
+
+TEST(Id3ReaderTest, EarlyExit) {
+  constexpr char mp3_data[] = {
+      'I', 'D', '3', '\x03', '\x00', '\x40', '\x00', '\x00', '\x00',
+      '\x2c',                          // Tag size = 44
+      '\x00', '\x00', '\x00', '\x0a',  // Extended header size = 10
+      '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00',
+      '\x00',
+      // A 15-byte COMM frame
+      'C', 'O', 'M', 'M', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00',
+      '\x01', '\x02', '\x03', '\x04', '\x05',
+      // A 15-byte GEOB frame
+      'G', 'E', 'O', 'B', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00',
+      '\x05', '\x04', '\x03', '\x02', '\x01'};
+  auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data));
+  riegeli::StringReader<> input(mp3_file_contents);
+
+  std::vector<Id3Frame> frames;
+  EXPECT_THAT(IterateOverId3Frames(input,
+                                   [&](const Id3Frame& frame) {
+                                     frames.push_back(frame);
+                                     return false;
+                                   }),
+              IsOk());
+
+  EXPECT_THAT(frames, testing::ElementsAre(Id3Frame{
+                          .offset = 24,
+                          .length = 15,
+                          .id = "COMM",
+                      }));
+}
+
+TEST(Id3ReaderTest, ReadsAllFrames) {
+  constexpr char mp3_data[] = {
+      'I', 'D', '3', '\x03', '\x00', '\x40', '\x00', '\x00', '\x00',
+      '\x2c',                          // Tag size = 44
+      '\x00', '\x00', '\x00', '\x0a',  // Extended header size = 10
+      '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00',
+      '\x00',
+      // A 15-byte COMM frame
+      'C', 'O', 'M', 'M', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00',
+      '\x01', '\x02', '\x03', '\x04', '\x05',
+      // A 15-byte GEOB frame
+      'G', 'E', 'O', 'B', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00',
+      '\x05', '\x04', '\x03', '\x02', '\x01'};
+  auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data));
+  riegeli::StringReader<> input(mp3_file_contents);
+
+  std::vector<Id3Frame> frames;
+  auto status = IterateOverId3Frames(input, [&](const Id3Frame& frame) {
+    frames.push_back(frame);
+    return true;
+  });
+  ASSERT_THAT(status, IsOk());
+  ASSERT_TRUE(status.value().has_extended_header);
+
+  EXPECT_THAT(frames, testing::UnorderedElementsAre(
+                          Id3Frame{
+                              .offset = 24,
+                              .length = 15,
+                              .id = "COMM",
+                          },
+                          Id3Frame{
+                              .offset = 39,
+                              .length = 15,
+                              .id = "GEOB",
+                          }));
+}
+
+TEST(Id3ReaderTest, FrameSizeExceedsRemainingTagSize) {
+  constexpr char mp3_data[] = {
+      'I', 'D', '3', '\x03', '\x00', '\x40', '\x00', '\x00', '\x00',
+      '\x22',                          // Tag size = 34 (syncsafe 0x22)
+      '\x00', '\x00', '\x00', '\x0a',  // Extended header size = 10
+      '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00',
+      '\x00',
+      // Frame: size = 0xFFFFFFF6
+      'F', 'I', 'D', 'S', '\xFF', '\xFF', '\xFF', '\xF6', '\x00', '\x00',
+      // Padding to satisfy the tag size (10 bytes)
+      '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00',
+      '\x00'};
+  auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data));
+  riegeli::StringReader<> input(mp3_file_contents);
+
+  int call_count = 0;
+  auto status = IterateOverId3Frames(input, [&](const Id3Frame& frame) {
+    call_count++;
+    return true;
+  });
+
+  EXPECT_THAT(status,
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("ID3 frame size exceeds remaining tag size")));
+  EXPECT_EQ(call_count, 0);
+}
+
+TEST(Id3ReaderTest, FrameLengthOneTerminates) {
+  constexpr char mp3_data[] = {
+      'I', 'D', '3', '\x03', '\x00', '\x40', '\x00', '\x00', '\x00',
+      '\x22',                          // Tag size = 34
+      '\x00', '\x00', '\x00', '\x0a',  // Extended header size = 10
+      '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00',
+      '\x00',
+      // Frame: size = 0xFFFFFFF7
+      'F', 'I', 'D', 'S', '\xFF', '\xFF', '\xFF', '\xF7', '\x00', '\x00',
+      // Padding
+      '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00',
+      '\x00'};
+  auto mp3_file_contents = std::string(mp3_data, sizeof(mp3_data));
+  riegeli::StringReader<> input(mp3_file_contents);
+
+  auto status =
+      IterateOverId3Frames(input, [](const Id3Frame& frame) { return true; });
+
+  EXPECT_THAT(status,
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("ID3 frame size exceeds remaining tag size")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/BUILD b/formats/jpeg/BUILD
new file mode 100644
index 0000000..982f3a0
--- /dev/null
+++ b/formats/jpeg/BUILD
@@ -0,0 +1,195 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "reader",
+    srcs = ["reader.cc"],
+    hdrs = ["reader.h"],
+    deps = [
+        ":box_header",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "reader_test",
+    srcs = ["reader_test.cc"],
+    deps = [
+        ":reader",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "box_header",
+    srcs = ["box_header.cc"],
+    hdrs = ["box_header.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "box_header_test",
+    srcs = ["box_header_test.cc"],
+    deps = [
+        ":box_header",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:str_format",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "c2pa_segment_handler",
+    srcs = ["c2pa_segment_handler.cc"],
+    hdrs = ["c2pa_segment_handler.h"],
+    deps = [
+        ":reader",
+        "//constants:labels",
+        "//jumbf:box",
+        "//jumbf:constants",
+        "//jumbf:parse",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "c2pa_segment_handler_test",
+    srcs = ["c2pa_segment_handler_test.cc"],
+    deps = [
+        ":c2pa_segment_handler",
+        ":reader",
+        "//jumbf:constants",
+        "@abseil-cpp//absl/container:btree",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":c2pa_segment_handler",
+        ":reader",
+        "//constants:labels",
+        "//formats:asset_box",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "//utils:byte_readers",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    deps = [
+        ":extractor",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor_result",
+        "//formats/jpeg/testing:app_segment_creator",
+        "//formats/jpeg/testing:jpeg_creator",
+        "//formats/jpeg/testing:jumbf_creator",
+        "//testing:jumbf_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/jpeg/assessor.cc b/formats/jpeg/assessor.cc
new file mode 100644
index 0000000..dc22e7f
--- /dev/null
+++ b/formats/jpeg/assessor.cc
@@ -0,0 +1,50 @@
+// 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 "formats/jpeg/assessor.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+
+namespace {
+
+// JPEG Marker which indicates the start of a JPEG file.
+const uint16_t kJpegStartingBytes = 0xffd8;
+
+}  // namespace
+
+absl::StatusOr<bool> JpegAssessor::IsSupported(riegeli::Reader& input) const {
+  int64_t starting_position = input.pos();
+
+  uint16_t marker;
+  if (!riegeli::ReadBigEndian<uint16_t>(input, marker)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; marker"));
+  }
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; position"));
+  }
+
+  return marker == kJpegStartingBytes;
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/assessor.h b/formats/jpeg/assessor.h
new file mode 100644
index 0000000..8dbc395
--- /dev/null
+++ b/formats/jpeg/assessor.h
@@ -0,0 +1,34 @@
+// 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_FORMATS_JPEG_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Assessor for the JPEG image format.
+class JpegAssessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_ASSESSOR_H_
diff --git a/formats/jpeg/assessor_test.cc b/formats/jpeg/assessor_test.cc
new file mode 100644
index 0000000..75251a6
--- /dev/null
+++ b/formats/jpeg/assessor_test.cc
@@ -0,0 +1,71 @@
+// 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 "formats/jpeg/assessor.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(IsSupportedTest, ErrorTooFewBytes) {
+  std::string image = "\xab";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(
+      JpegAssessor().IsSupported(input),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof")));
+  EXPECT_EQ(input.pos(), 0);
+}
+
+TEST(IsSupportedTest, FalseForInvalidBeginning) {
+  std::string image = "\xab\xcd";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(JpegAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+}
+
+TEST(IsSupportedTest, ValidStartingBytes) {
+  std::string image = "\xff\xd8";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(JpegAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 0);
+}
+
+TEST(IsSupportedTest, ValidStartingBytesAtOffset2) {
+  std::string image = "ab\xff\xd8";
+  riegeli::StringReader<> input(image);
+
+  // Invalid at 0
+  EXPECT_THAT(JpegAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(JpegAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/box_header.cc b/formats/jpeg/box_header.cc
new file mode 100644
index 0000000..9e924db
--- /dev/null
+++ b/formats/jpeg/box_header.cc
@@ -0,0 +1,139 @@
+// 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 "formats/jpeg/box_header.h"
+
+#include <cstdint>
+#include <ostream>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+namespace {
+
+const uint32_t kJpegStartMarker = 0xffd8;
+const uint32_t kJpegEndMarker = 0xffd9;
+
+// Converts a JPEG marker to a human readable label.
+// Sourced from Table B.1 of ISO/IEC 10918-1:1994
+std::string ConvertMarkerToLabel(uint16_t marker) {
+  if (0xff00 != (marker & 0xff00) || marker == 0xffff) {
+    // Markers are always 0xFFXX
+    // 0xFFFF is not a valid JPEG marker
+    return absl::StrFormat("%04x", marker);
+  }
+  if (0x00c0 == (marker & 0x00f0)) {
+    switch (marker) {
+      case 0xffc4:
+        return "DHT";  // Define Huffman Table(s)
+      case 0xffc8:
+        return "JPG";  // Reserved for JPG extensions
+      case 0xffcc:
+        return "DAC";  // Define arithmetic coding conditioning(s)
+      default:
+        // Start Of Frame markers labeled numerically
+        return absl::StrFormat("SOF%d", (marker & 0x000f));
+    }
+  }
+  if (0x00d0 == (marker & 0x00f0)) {
+    switch (marker) {
+      case 0xffd8:
+        return "SOI";  // Start of Image
+      case 0xffd9:
+        return "EOI";  // End of Image
+      case 0xffda:
+        return "SOS";  // Start of Scan
+      case 0xffdb:
+        return "DQT";  // Define Quantization Table(s)
+      case 0xffdc:
+        return "DNL";  // Define Number of Lines
+      case 0xffdd:
+        return "DRI";  // Define Restart Interval
+      case 0xffde:
+        return "DHP";  // Define Hierarchical Progression
+      case 0xffdf:
+        return "EXP";  // Expand reference component(s)
+      default:
+        // Restart with modulo 8 markers labeled numerically
+        return absl::StrFormat("RST%d", (marker & 0x000f));
+    }
+  }
+  if (0x00e0 == (marker & 0x00f0)) {
+    // Reserved for APP markers labeled numerically
+    return absl::StrFormat("APP%d", (marker & 0x000f));
+  }
+  if (0x00f0 == (marker & 0x00f0)) {
+    switch (marker) {
+      case 0xfffe:
+        return "COM";  // Comment
+      default:
+        // Reserved for JPEG extensions labeled numerically
+        return absl::StrFormat("JPG%d", (marker & 0x000f));
+    }
+  }
+  if (marker == 0xff01) {
+    return "TEM";  // For temporary private use in arithmetic coding
+  }
+  return "RES";  // Reserved
+}
+}  // namespace
+
+std::string JpegBoxHeader::label() const {
+  return ConvertMarkerToLabel(this->type);
+}
+
+absl::StatusOr<JpegBoxHeader> ConsumeJpegBoxHeader(riegeli::Reader& input) {
+  JpegBoxHeader b = {.offset = input.pos()};
+  if (!riegeli::ReadBigEndian<uint16_t>(input, b.type)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read type"));
+  }
+
+  if (0xff00 != (b.type & 0xff00) || b.type == 0xffff) {
+    // Invalid marker.
+    return absl::InvalidArgumentError(
+        absl::StrFormat("Invalid JPEG marker: %d", b.type));
+  }
+
+  if (b.type == kJpegStartMarker || b.type == kJpegEndMarker ||
+      b.type == 0xff01 || (b.type >= 0xffd0 && b.type <= 0xffd7)) {
+    // No payload.
+    b.size = sizeof(uint16_t);
+    return b;
+  }
+
+  uint16_t chunk_size;
+  if (!riegeli::ReadBigEndian<uint16_t>(input, chunk_size)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read size"));
+  }
+  if (chunk_size < 2) {
+    return absl::InvalidArgumentError(
+        absl::StrFormat("Invalid JPEG segment size: %d", chunk_size));
+  }
+  b.size = chunk_size + sizeof(b.type);
+  return b;
+}
+
+void PrintTo(const JpegBoxHeader& x, ::std::ostream* os) {
+  *os << absl::StrCat("{type: ", x.type, ", offset: ", x.offset,
+                      ", size: ", x.size, ", label: \"", x.label(), "\"}");
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/box_header.h b/formats/jpeg/box_header.h
new file mode 100644
index 0000000..1d2506e
--- /dev/null
+++ b/formats/jpeg/box_header.h
@@ -0,0 +1,42 @@
+// 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_FORMATS_JPEG_BOX_HEADER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_BOX_HEADER_H_
+
+#include <cstdint>
+#include <iosfwd>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+struct JpegBoxHeader {
+  uint16_t type;              // The marker of the box.
+  uint64_t offset;            // The offset of the box in the file.
+  uint64_t size;              // The size of the box (including the marker).
+  std::string label() const;  // The human-readable box type (marker).
+
+  friend bool operator==(const JpegBoxHeader&, const JpegBoxHeader&) = default;
+};
+
+void PrintTo(const JpegBoxHeader& x, ::std::ostream* os);
+
+// Reads and returns the box header.
+absl::StatusOr<JpegBoxHeader> ConsumeJpegBoxHeader(riegeli::Reader& input);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_BOX_HEADER_H_
diff --git a/formats/jpeg/box_header_test.cc b/formats/jpeg/box_header_test.cc
new file mode 100644
index 0000000..77ffdf9
--- /dev/null
+++ b/formats/jpeg/box_header_test.cc
@@ -0,0 +1,167 @@
+// 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 "formats/jpeg/box_header.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_format.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::Range;
+using ::testing::TestParamInfo;
+
+using BoxHeaderTest = testing::TestWithParam<uint32_t>;
+
+INSTANTIATE_TEST_SUITE_P(MarkerTests, BoxHeaderTest,
+                         Range(static_cast<uint32_t>(0x0000feff),
+                               static_cast<uint32_t>(0x00010000)),
+                         [](const TestParamInfo<uint32_t>& info) {
+                           return absl::StrFormat(
+                               "%04x", static_cast<uint16_t>(info.param));
+                         });
+
+TEST_P(BoxHeaderTest, ConsumeJpegBoxHeaderTest) {
+  std::string data("\0\0\0\4\1\1", 6);
+  data[0] = static_cast<uint8_t>((GetParam() & 0x0000ff00) >> 8);
+  data[1] = static_cast<uint8_t>((GetParam() & 0x000000ff));
+
+  riegeli::StringReader<> input(data);
+
+  if (GetParam() < 0xff00 || GetParam() == 0xffff) {
+    // Invalid marker.
+    EXPECT_THAT(ConsumeJpegBoxHeader(input),
+                StatusIs(absl::StatusCode::kInvalidArgument));
+    return;
+  }
+
+  auto header_or = ConsumeJpegBoxHeader(input);
+  ASSERT_THAT(header_or, IsOk());
+
+  JpegBoxHeader header = *header_or;
+  EXPECT_EQ(header.offset, 0);
+  EXPECT_EQ(header.type, GetParam());
+  if (GetParam() == 0xff01 || (GetParam() >= 0xffd0 && GetParam() <= 0xffd9)) {
+    EXPECT_EQ(header.size, 2);
+  } else {
+    EXPECT_EQ(header.size, 6);  // Length + 2 for the marker
+  }
+}
+
+TEST(BoxHeaderTest, ConsumeJpegBoxHeaderEmptyInput) {
+  std::string data("");
+  riegeli::StringReader<> input(data);
+  EXPECT_THAT(ConsumeJpegBoxHeader(input),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       ::testing::HasSubstr("Failed to read type")));
+}
+
+TEST(BoxHeaderTest, ConsumeJpegBoxHeaderShortInputForSize) {
+  std::string data("\xff\xe1\x00", 3);
+  riegeli::StringReader<> input(data);
+  EXPECT_THAT(ConsumeJpegBoxHeader(input),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       ::testing::HasSubstr("Failed to read size")));
+}
+
+TEST_P(BoxHeaderTest, ConvertMarkerToLabelTest) {
+  uint16_t marker = static_cast<uint16_t>(GetParam() & 0x0000ffff);
+  JpegBoxHeader header = {.type = marker};
+
+  switch (marker) {
+    case 0xfeff:
+      EXPECT_EQ(header.label(), "feff");
+      return;
+    case 0xff01:
+      EXPECT_EQ(header.label(), "TEM");
+      return;
+    case 0xffc4:
+      EXPECT_EQ(header.label(), "DHT");
+      return;
+    case 0xffc8:
+      EXPECT_EQ(header.label(), "JPG");
+      return;
+    case 0xffcc:
+      EXPECT_EQ(header.label(), "DAC");
+      return;
+    case 0xffd8:
+      EXPECT_EQ(header.label(), "SOI");
+      return;
+    case 0xffd9:
+      EXPECT_EQ(header.label(), "EOI");
+      return;
+    case 0xffda:
+      EXPECT_EQ(header.label(), "SOS");
+      return;
+    case 0xffdb:
+      EXPECT_EQ(header.label(), "DQT");
+      return;
+    case 0xffdc:
+      EXPECT_EQ(header.label(), "DNL");
+      return;
+    case 0xffdd:
+      EXPECT_EQ(header.label(), "DRI");
+      return;
+    case 0xffde:
+      EXPECT_EQ(header.label(), "DHP");
+      return;
+    case 0xffdf:
+      EXPECT_EQ(header.label(), "EXP");
+      return;
+    case 0xfffe:
+      EXPECT_EQ(header.label(), "COM");
+      return;
+    case 0xffff:
+      EXPECT_EQ(header.label(), "ffff");
+      return;
+  }
+
+  if ((marker & 0x00f0) == 0x00c0) {
+    EXPECT_EQ(header.label(), absl::StrFormat("SOF%d", (marker & 0x000f)));
+    return;
+  }
+  if (marker >= 0xffd0 && marker <= 0xffd7) {
+    EXPECT_EQ(header.label(), absl::StrFormat("RST%d", (marker & 0x000f)));
+    return;
+  }
+  if ((marker & 0x00f0) == 0x00e0) {
+    EXPECT_EQ(header.label(), absl::StrFormat("APP%d", (marker & 0x000f)));
+    return;
+  }
+  if (marker >= 0xfff0 && marker <= 0xfffd) {
+    EXPECT_EQ(header.label(), absl::StrFormat("JPG%d", (marker & 0x000f)));
+    return;
+  }
+  EXPECT_EQ(header.label(), "RES");
+}
+
+TEST(BoxHeaderTest, PrintToJpegBoxHeader) {
+  JpegBoxHeader header = {.type = 0xffe1, .offset = 10, .size = 20};
+  EXPECT_EQ(testing::PrintToString(header),
+            "{type: 65505, offset: 10, size: 20, label: \"APP1\"}");
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/c2pa_segment_handler.cc b/formats/jpeg/c2pa_segment_handler.cc
new file mode 100644
index 0000000..45f8779
--- /dev/null
+++ b/formats/jpeg/c2pa_segment_handler.cc
@@ -0,0 +1,246 @@
+// 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 "formats/jpeg/c2pa_segment_handler.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "absl/types/span.h"
+#include "constants/labels.h"
+#include "formats/jpeg/reader.h"
+#include "jumbf/box.h"
+#include "jumbf/constants.h"
+#include "jumbf/parse.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;  // 10 MiB
+constexpr size_t kMaxSegmentsPerId = 1000;
+
+bool IsValidNextSegment(const JpegSegment& prev_segment,
+                        const JpegSegment& curr_segment) {
+  if (!prev_segment.app11_info.has_value() ||
+      !curr_segment.app11_info.has_value()) {
+    // Missing APP11 information, invalid.
+    return false;
+  }
+  if (!prev_segment.app11_info->jumbf_data.has_value() ||
+      !curr_segment.app11_info->jumbf_data.has_value()) {
+    // Segments do not contain JUMBF data, invalid.
+    return false;
+  }
+
+  // C2PA Spec requires sequence numbers to be increasing.
+  if (prev_segment.app11_info->sequence_number >=
+      curr_segment.app11_info->sequence_number) {
+    // Sequence number is not increasing, invalid.
+    return false;
+  }
+
+  // C2PA Spec requires segments to be contiguous.
+  if ((prev_segment.offset + prev_segment.length) != curr_segment.offset) {
+    // Segments are not contiguous, invalid.
+    return false;
+  }
+
+  // All segments must have the same lbox and xlbox.
+  if (prev_segment.app11_info->jumbf_data->lbox !=
+      curr_segment.app11_info->jumbf_data->lbox) {
+    // Lbox is different, invalid.
+    return false;
+  }
+  if (prev_segment.app11_info->jumbf_data->xlbox !=
+      curr_segment.app11_info->jumbf_data->xlbox) {
+    // Xlbox is different, invalid.
+    return false;
+  }
+
+  return true;
+}
+
+absl::StatusOr<std::string> ExtractCompletePayload(
+    riegeli::Reader& input, absl::Span<const JpegSegment> segments) {
+  if (segments.empty() || !segments.front().app11_info.has_value() ||
+      !segments.front().app11_info->jumbf_data.has_value()) {
+    return "";
+  }
+  uint64_t expected_payload_size =
+      segments.front().app11_info->jumbf_data->lbox == 1
+          ? segments.front().app11_info->jumbf_data->xlbox
+          : segments.front().app11_info->jumbf_data->lbox;
+  if (expected_payload_size == 0 || expected_payload_size > kMaxPayloadSize) {
+    return absl::InvalidArgumentError(absl::Substitute(
+        "Declared JUMBF payload size is invalid ($0)", expected_payload_size));
+  }
+
+  std::string payload;
+  for (const JpegSegment& segment : segments) {
+    if (!segment.app11_info.has_value() ||
+        !segment.app11_info->jumbf_data.has_value()) {
+      // Segment does not contain JUMBF data within an APP11 segment, skip it.
+      continue;
+    }
+
+    uint64_t payload_offset = segment.app11_info->payload_offset;
+    uint64_t payload_length = segment.app11_info->payload_length;
+    if (!payload.empty()) {
+      // This is not the first segment, so skip the JUMBF header data
+      payload_offset = segment.app11_info->jumbf_data->payload_offset;
+      payload_length = segment.app11_info->jumbf_data->payload_length;
+    }
+
+    if (payload.size() + payload_length > kMaxPayloadSize) {
+      return absl::InvalidArgumentError(
+          absl::Substitute("JPEG segment is too large to extract ($0 > $1)",
+                           payload.size() + payload_length, kMaxPayloadSize));
+    }
+
+    if (!input.Seek(payload_offset) || input.pos() != payload_offset) {
+      return absl::InvalidArgumentError("Failed to seek to payload offset");
+    }
+
+    std::string segment_payload;
+    if (!input.Read(payload_length, segment_payload)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read payload"));
+    }
+    payload.append(segment_payload);
+  }
+
+  if (payload.size() != expected_payload_size) {
+    // Incomplete payload, return empty string.
+    return "";
+  }
+
+  return payload;
+}
+
+bool IsC2paManifestStore(absl::string_view payload) {
+  absl::StatusOr<jumbf::SuperBox> box =
+      jumbf::ConsumeSuperBox(&payload, /*recursion_limit=*/0);
+  if (!box.ok()) {
+    // If we can't read a JUMBF Superbox, it's not a C2PA Manifest Store,
+    // ignore.
+    return false;
+  }
+
+  if (box->description.type_uuid != kManifestStoreUuid) {
+    // JUMBF Superbox is not a C2PA Manifest Store, ignore.
+    return false;
+  };
+
+  if (!box->description.requestable) {
+    // Manifest store must be requestable.
+    return false;
+  }
+
+  return box->description.label == kManifestStoreLabel;
+}
+
+}  // namespace
+
+void C2paSegmentHandler::RecordSegment(JpegSegment segment) {
+  if (!segment.app11_info.has_value() ||
+      !segment.app11_info->jumbf_data.has_value()) {
+    // Segment does not contain APP11 information, skip it.
+    return;
+  }
+
+  const JpegApp11Segment& app11_info = *segment.app11_info;
+  const JpegJumbfData& jumbf_data = *app11_info.jumbf_data;
+
+  if (ignored_segment_ids_.contains(app11_info.segment_id)) {
+    // Already ignored this segment ID, skip it.
+    return;
+  }
+
+  if (app11_info.segment_id == 0) {
+    // Invalid segment id, skip it.
+    return;
+  }
+
+  if (jumbf_data.tbox != jumbf::kSuperBoxType) {
+    // Segment is not a JUMBF Superbox, skip it.
+    return;
+  }
+
+  auto [it, inserted] = segment_map_.try_emplace(app11_info.segment_id);
+  if (inserted) {
+    // First segment for this segment ID, record it.
+    it->second.push_back(std::move(segment));
+  } else {
+    // Segment ID already exists.
+    if (it->second.size() >= kMaxSegmentsPerId ||
+        !IsValidNextSegment(it->second.back(), segment)) {
+      // Exceeded max segments per ID or invalid continuing segment, remove from
+      // map and ignore.
+      segment_map_.erase(it);
+      ignored_segment_ids_.insert(app11_info.segment_id);
+    } else {
+      // Valid continuing segment, record it.
+      it->second.push_back(std::move(segment));
+    }
+  }
+}
+
+absl::StatusOr<C2paSegmentHandler::Segment> C2paSegmentHandler::GetC2paSegment(
+    riegeli::Reader& input) const {
+  C2paSegmentHandler::Segment result;
+  int64_t c2pa_count = 0;
+
+  for (const auto& [segment_id, segments] : segment_map_) {
+    if (segments.empty()) {
+      continue;
+    }
+    ABSL_ASSIGN_OR_RETURN(std::string payload,
+                          ExtractCompletePayload(input, segments));
+    if (payload.empty()) {
+      // Incomplete payload, ignore.
+      continue;
+    }
+
+    if (IsC2paManifestStore(payload)) {
+      ++c2pa_count;
+      if (c2pa_count > 1) {
+        return absl::NotFoundError("Multiple manifest stores found");
+      }
+
+      result.manifest_store = std::move(payload);
+      result.segment = JpegSegment{
+          .offset = segments.front().offset,
+          .marker_label = "C2PA",
+          .length = (segments.back().offset - segments.front().offset) +
+                    segments.back().length,
+      };
+    }
+  }
+
+  if (c2pa_count == 0) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return std::move(result);
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/c2pa_segment_handler.h b/formats/jpeg/c2pa_segment_handler.h
new file mode 100644
index 0000000..728332d
--- /dev/null
+++ b/formats/jpeg/c2pa_segment_handler.h
@@ -0,0 +1,60 @@
+// 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_FORMATS_JPEG_C2PA_SEGMENT_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_C2PA_SEGMENT_HANDLER_H_
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/status/statusor.h"
+#include "formats/jpeg/reader.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+class C2paSegmentHandler {
+ public:
+  struct Segment {
+    JpegSegment segment;
+    std::string manifest_store;
+  };
+
+  void RecordSegment(JpegSegment segment);
+
+  absl::StatusOr<C2paSegmentHandler::Segment> GetC2paSegment(
+      riegeli::Reader& input) const;
+
+ protected:
+  const absl::flat_hash_map<uint16_t, std::vector<JpegSegment>>& GetSegmentMap()
+      const {
+    return segment_map_;
+  }
+
+  const absl::flat_hash_set<uint16_t>& GetIgnoredSegmentIds() const {
+    return ignored_segment_ids_;
+  }
+
+ private:
+  absl::flat_hash_set<uint16_t> ignored_segment_ids_;
+  absl::flat_hash_map<uint16_t, std::vector<JpegSegment>> segment_map_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_C2PA_SEGMENT_HANDLER_H_
diff --git a/formats/jpeg/c2pa_segment_handler_test.cc b/formats/jpeg/c2pa_segment_handler_test.cc
new file mode 100644
index 0000000..afecd4a
--- /dev/null
+++ b/formats/jpeg/c2pa_segment_handler_test.cc
@@ -0,0 +1,408 @@
+// 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 "formats/jpeg/c2pa_segment_handler.h"
+
+#include <cstdint>
+#include <vector>
+
+#include "absl/container/btree_set.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "formats/jpeg/reader.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/constants.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::SizeIs;
+
+class TestC2paSegmentHandler : public C2paSegmentHandler {
+ public:
+  std::vector<std::vector<JpegSegment>> AllRecorded() const {
+    absl::btree_set<uint16_t> segment_ids;
+    for (const auto& [segment_id, segments] : GetSegmentMap()) {
+      segment_ids.insert(segment_id);
+    }
+
+    std::vector<std::vector<JpegSegment>> result;
+    for (uint16_t segment_id : segment_ids) {
+      result.push_back(GetSegmentMap().at(segment_id));
+    }
+    return result;
+  }
+
+  absl::btree_set<uint16_t> AllIgnored() const {
+    return absl::btree_set<uint16_t>(GetIgnoredSegmentIds().begin(),
+                                     GetIgnoredSegmentIds().end());
+  }
+};
+
+TEST(RecordSegmentTest, DoesNotRecordSegmentWithoutApp11Info) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), IsEmpty());
+}
+
+TEST(RecordSegmentTest, DoesNotRecordSegmentIdZero) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(
+      JpegSegment{.app11_info = JpegApp11Segment{.segment_id = 0}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), IsEmpty());
+}
+
+TEST(RecordSegmentTest, DoesNotRecordSegmentWithoutJumbfData) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(
+      JpegSegment{.app11_info = JpegApp11Segment{.segment_id = 1}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), IsEmpty());
+}
+
+TEST(RecordSegmentTest, DoesNotRecordSegmentIncorrectTbox) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .app11_info = JpegApp11Segment{
+          .segment_id = 1, .jumbf_data = JpegJumbfData{.tbox = 0x1234}}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), IsEmpty());
+}
+
+TEST(RecordSegmentTest, RecordSegment) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .app11_info = JpegApp11Segment{
+          .segment_id = 1,
+          .jumbf_data = JpegJumbfData{.tbox = jumbf::kSuperBoxType}}});
+  EXPECT_THAT(
+      handler.AllRecorded(),
+      ElementsAre(ElementsAre(JpegSegment{
+          .app11_info = JpegApp11Segment{
+              .segment_id = 1,
+              .jumbf_data = JpegJumbfData{.tbox = jumbf::kSuperBoxType}}})));
+  EXPECT_THAT(handler.AllIgnored(), IsEmpty());
+}
+
+TEST(RecordSegmentTest, DoesNotRecordNextSegmentIncorrectSequence) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .tbox = jumbf::kSuperBoxType,
+                                     }}});
+  handler.RecordSegment(JpegSegment{
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .tbox = jumbf::kSuperBoxType,
+                                     }}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), ElementsAre(1));
+}
+
+TEST(RecordSegmentTest, DoesNotRecordNextSegmentNotContiguous) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .offset = 100,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .tbox = jumbf::kSuperBoxType,
+                                     }}});
+  handler.RecordSegment(JpegSegment{
+      .offset = 200,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 2,
+                                     .jumbf_data = JpegJumbfData{
+                                         .tbox = jumbf::kSuperBoxType,
+                                     }}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), ElementsAre(1));
+}
+
+TEST(RecordSegmentTest, DoesNotRecordNextSegmentDifferentLbox) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .offset = 100,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 10,
+                                         .tbox = jumbf::kSuperBoxType,
+                                     }}});
+  handler.RecordSegment(JpegSegment{
+      .offset = 110,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 2,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 20,
+                                         .tbox = jumbf::kSuperBoxType,
+                                     }}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), ElementsAre(1));
+}
+
+TEST(RecordSegmentTest, DoesNotRecordNextSegmentDifferentXlbox) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .offset = 100,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 1,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 10,
+                                     }}});
+  handler.RecordSegment(JpegSegment{
+      .offset = 110,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 2,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 1,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 20,
+                                     }}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), ElementsAre(1));
+}
+
+TEST(RecordSegmentTest, RecordsNextSegment) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .offset = 100,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 10,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                     }}});
+  handler.RecordSegment(JpegSegment{
+      .offset = 110,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 2,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 10,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                     }}});
+  EXPECT_THAT(handler.AllRecorded(),
+              ElementsAre(ElementsAre(
+                  JpegSegment{
+                      .offset = 100,
+                      .length = 10,
+                      .app11_info =
+                          JpegApp11Segment{.segment_id = 1,
+                                           .sequence_number = 1,
+                                           .jumbf_data =
+                                               JpegJumbfData{
+                                                   .lbox = 10,
+                                                   .tbox = jumbf::kSuperBoxType,
+                                                   .xlbox = 0,
+                                               }},
+                  },
+                  JpegSegment{
+                      .offset = 110,
+                      .length = 10,
+                      .app11_info =
+                          JpegApp11Segment{.segment_id = 1,
+                                           .sequence_number = 2,
+                                           .jumbf_data =
+                                               JpegJumbfData{
+                                                   .lbox = 10,
+                                                   .tbox = jumbf::kSuperBoxType,
+                                                   .xlbox = 0,
+                                               }},
+                  })));
+  EXPECT_THAT(handler.AllIgnored(), IsEmpty());
+}
+
+TEST(RecordSegmentTest, DoesNotRecordIgnoredSegment) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .offset = 100,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 10,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                     }}});
+  handler.RecordSegment(JpegSegment{
+      .offset = 110,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 10,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                     }}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), ElementsAre(1));
+
+  handler.RecordSegment(JpegSegment{
+      .offset = 210,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 10,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                     }}});
+
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), ElementsAre(1));
+}
+
+TEST(RecordSegmentTest, DoesNotRecordMoreThanMaxSegmentsPerId) {
+  TestC2paSegmentHandler handler;
+  for (int i = 1; i <= 1000; ++i) {
+    handler.RecordSegment(JpegSegment{
+        .offset = static_cast<uint64_t>((i - 1) * 10),
+        .length = 10,
+        .app11_info =
+            JpegApp11Segment{.segment_id = 1,
+                             .sequence_number = static_cast<uint32_t>(i),
+                             .jumbf_data = JpegJumbfData{
+                                 .lbox = 10,
+                                 .tbox = jumbf::kSuperBoxType,
+                                 .xlbox = 0,
+                             }}});
+  }
+  EXPECT_THAT(handler.AllRecorded(), SizeIs(1));
+  EXPECT_THAT(handler.AllRecorded().front(), SizeIs(1000));
+  EXPECT_THAT(handler.AllIgnored(), IsEmpty());
+
+  handler.RecordSegment(JpegSegment{
+      .offset = 10000,
+      .length = 10,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1001,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 10,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                     }}});
+  EXPECT_THAT(handler.AllRecorded(), IsEmpty());
+  EXPECT_THAT(handler.AllIgnored(), ElementsAre(1));
+}
+
+TEST(GetC2paSegmentTest, RejectsDeclaredPayloadSizeTooLarge) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .offset = 0,
+      .length = 100,
+      .app11_info = JpegApp11Segment{
+          .segment_id = 1,
+          .sequence_number = 1,
+          .payload_offset = 0,
+          .payload_length = 10,
+          .jumbf_data = JpegJumbfData{
+              .lbox = 1024 * 1024 * 10 + 1,  // > kMaxPayloadSize (10 MiB)
+              .tbox = jumbf::kSuperBoxType,
+              .xlbox = 0,
+          }}});
+  std::string data(10, 'A');
+  riegeli::StringReader input(data);
+  EXPECT_THAT(handler.GetC2paSegment(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Declared JUMBF payload size is invalid")));
+}
+
+TEST(GetC2paSegmentTest, RejectsDeclaredPayloadSizeZero) {
+  TestC2paSegmentHandler handler;
+  handler.RecordSegment(JpegSegment{
+      .offset = 0,
+      .length = 100,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .payload_offset = 0,
+                                     .payload_length = 10,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = 0,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                     }}});
+  std::string data(10, 'A');
+  riegeli::StringReader input(data);
+  EXPECT_THAT(handler.GetC2paSegment(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Declared JUMBF payload size is invalid")));
+}
+
+TEST(GetC2paSegmentTest, RejectsCumulativePayloadSizeExceedingMax) {
+  TestC2paSegmentHandler handler;
+  const uint64_t kSixMiB = 6 * 1024 * 1024;
+  const uint64_t kFiveMiB = 5 * 1024 * 1024;
+  const uint64_t kTenMiB = 10 * 1024 * 1024;
+  handler.RecordSegment(JpegSegment{
+      .offset = 0,
+      .length = 100,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 1,
+                                     .payload_offset = 0,
+                                     .payload_length = kSixMiB,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = kTenMiB,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                         .payload_offset = 0,
+                                         .payload_length = kSixMiB,
+                                     }}});
+  handler.RecordSegment(JpegSegment{
+      .offset = 100,
+      .length = 100,
+      .app11_info = JpegApp11Segment{.segment_id = 1,
+                                     .sequence_number = 2,
+                                     .payload_offset = kSixMiB,
+                                     .payload_length = kFiveMiB,
+                                     .jumbf_data = JpegJumbfData{
+                                         .lbox = kTenMiB,
+                                         .tbox = jumbf::kSuperBoxType,
+                                         .xlbox = 0,
+                                         .payload_offset = kSixMiB,
+                                         .payload_length = kFiveMiB,
+                                     }}});
+  std::string data(kSixMiB, 'A');
+  riegeli::StringReader input(data);
+  EXPECT_THAT(handler.GetC2paSegment(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("JPEG segment is too large to extract")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/extractor.cc b/formats/jpeg/extractor.cc
new file mode 100644
index 0000000..1176b6f
--- /dev/null
+++ b/formats/jpeg/extractor.cc
@@ -0,0 +1,180 @@
+// 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 "formats/jpeg/extractor.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/jpeg/c2pa_segment_handler.h"
+#include "formats/jpeg/reader.h"
+#include "jumbf/utils.h"
+#include "riegeli/bytes/reader.h"
+#include "utils/byte_readers.h"
+
+namespace credentio {
+namespace {
+
+// APP11 Marker and APP11 JPEG Extension Type values are sourced from
+// Section A.4 JPEG XT boxes of the ISO/IEC 18477-3:2023
+const uint16_t kApp11ExtensionType = 0x4a50;
+
+}  // namespace
+
+absl::StatusOr<std::string> JpegExtractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  C2paSegmentHandler segment_handler;
+
+  ABSL_RETURN_IF_ERROR(
+      IterateOverJpegSegments(input, [&segment_handler](JpegSegment segment) {
+        segment_handler.RecordSegment(std::move(segment));
+        return true;
+      }));
+
+  ABSL_ASSIGN_OR_RETURN(auto c2pa_segment,
+                        segment_handler.GetC2paSegment(input));
+
+  return std::move(c2pa_segment.manifest_store);
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+JpegExtractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                            ExtractOptions options) const {
+  C2paSegmentHandler c2pa_handler;
+  ABSL_RETURN_IF_ERROR(IterateOverJpegSegments(
+      input,
+      [&c2pa_handler, &options, &input](JpegSegment segment) {
+        if (options.end_offset >= 0 &&
+            (segment.offset + segment.length) > options.end_offset) {
+          // This segment starts or ends beyond the window, ignore, stop reading
+          // and move the input's position to the declared end.
+          if (!input.Seek(options.end_offset)) {
+            return absl::StatusOr<bool>(input.StatusOrAnnotate(
+                absl::DataLossError("Failed to seek to end offset")));
+          }
+          return absl::StatusOr<bool>(false);  // stop iteration
+        }
+
+        c2pa_handler.RecordSegment(std::move(segment));
+        return absl::StatusOr<bool>(true);
+      },
+      options.end_offset));
+
+  absl::StatusOr<C2paSegmentHandler::Segment> c2pa_segment =
+      c2pa_handler.GetC2paSegment(input);
+  if (!c2pa_segment.ok()) {
+    if (options.requires_c2pa) {
+      return c2pa_segment.status();
+    }
+    return std::nullopt;
+  }
+
+  return ByteRange{
+      .offset = c2pa_segment->segment.offset,
+      .length = c2pa_segment->segment.length,
+  };
+}
+
+absl::StatusOr<std::vector<AssetBox>> JpegExtractor::ExtractBoxes(
+    riegeli::Reader& input, ExtractOptions options) const {
+  C2paSegmentHandler c2pa_handler;
+  std::vector<AssetBox> boxes;
+  ABSL_RETURN_IF_ERROR(IterateOverJpegSegments(
+      input,
+      [&boxes, &c2pa_handler](JpegSegment segment) {
+        boxes.push_back(AssetBox{
+            .identifier = segment.marker_label,
+            .byte_range = {.offset = segment.offset, .length = segment.length},
+        });
+
+        c2pa_handler.RecordSegment(std::move(segment));
+        return true;
+      },
+      options.end_offset));
+
+  absl::StatusOr<C2paSegmentHandler::Segment> c2pa_segment =
+      c2pa_handler.GetC2paSegment(input);
+  if (!c2pa_segment.ok()) {
+    if (options.requires_c2pa) {
+      return c2pa_segment.status();
+    }
+    return boxes;
+  }
+
+  uint64_t c2pa_start = c2pa_segment->segment.offset;
+  uint64_t c2pa_end = c2pa_start + c2pa_segment->segment.length;
+
+  std::vector<AssetBox> merged_boxes;
+  merged_boxes.reserve(boxes.size());
+  for (const auto& box : boxes) {
+    uint64_t box_start = box.byte_range.offset;
+    uint64_t box_end = box_start + box.byte_range.length;
+
+    if (box_end <= c2pa_start || box_start >= c2pa_end) {
+      // Box is before or after the C2PA segment, add it.
+      merged_boxes.push_back(std::move(box));
+      continue;
+    }
+
+    if (box_end == c2pa_end) {
+      // Box is the last C2PA Segment, add the box
+      merged_boxes.push_back(AssetBox{
+          .identifier = "C2PA",
+          .byte_range = {.offset = c2pa_start,
+                         .length = c2pa_segment->segment.length},
+      });
+      continue;
+    }
+
+    // Box is inside the C2PA segment, skip it.
+  }
+  return merged_boxes;
+}
+
+bool JpegExtractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  absl::StatusOr<uint16_t> extension_type = ConsumeUint<uint16_t>(&payload);
+  if (!extension_type.ok() || *extension_type != kApp11ExtensionType) {
+    return false;
+  }
+
+  // Segment ID
+  auto iter_status = SkipBytes<uint16_t>(&payload);
+  if (!iter_status.ok()) {
+    return false;
+  }
+
+  // Sequence Number
+  iter_status = SkipBytes<uint32_t>(&payload);
+  if (!iter_status.ok()) {
+    return false;
+  }
+
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/extractor.h b/formats/jpeg/extractor.h
new file mode 100644
index 0000000..eee77d3
--- /dev/null
+++ b/formats/jpeg/extractor.h
@@ -0,0 +1,52 @@
+// 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_FORMATS_JPEG_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_byte_info.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_jpeg_specific_handling
+// for more details.
+class JpegExtractor : public FormatExtractor {
+ public:
+  ~JpegExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EXTRACTOR_H_
diff --git a/formats/jpeg/extractor_test.cc b/formats/jpeg/extractor_test.cc
new file mode 100644
index 0000000..df119ff
--- /dev/null
+++ b/formats/jpeg/extractor_test.cc
@@ -0,0 +1,739 @@
+// 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 "formats/jpeg/extractor.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor_result.h"
+#include "formats/jpeg/testing/app_segment_creator.h"
+#include "formats/jpeg/testing/jpeg_creator.h"
+#include "formats/jpeg/testing/jumbf_creator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/jumbf_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+
+#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
+
+std::vector<AssetBox> CreateBoxes(
+    std::vector<std::pair<std::string, uint32_t>> expected) {
+  uint64_t offset = 0;
+  std::vector<AssetBox> boxes;
+  for (const auto& name_and_size : expected) {
+    boxes.push_back(AssetBox{
+        .identifier = name_and_size.first,
+        .byte_range = {.offset = offset, .length = name_and_size.second},
+    });
+    offset += name_and_size.second;
+  }
+  return boxes;
+}
+
+struct PayloadInput {
+  std::string payload;
+  JumbfCreatorParams jumbf_creator_params;
+  AppSegmentParams app_segment_params;
+};
+
+struct TestInput {
+  std::string name;
+  std::vector<PayloadInput> payload = {};
+  JpegParams jpeg_params = {};
+  void (*modify_jumbf_boxes)(std::vector<AppSegmentParams>*) = nullptr;
+  void (*modify_app_segments)(JpegParams*) = nullptr;
+
+  absl::StatusOr<ExtractorResult> expected =
+      absl::InvalidArgumentError("Unsupported hard binding type");
+};
+
+class ExtractorTest : public testing::TestWithParam<TestInput> {
+ private:
+  std::vector<AppSegmentParams> PopulateJumbfBoxes(TestInput input) {
+    std::vector<AppSegmentParams> jumbf_boxes;
+    jumbf_boxes.reserve(input.payload.size());
+
+    for (const auto& payload : input.payload) {
+      JumbfCreatorParams jumbf_params = payload.jumbf_creator_params;
+      jumbf_params.payload = payload.payload;
+
+      AppSegmentParams app_segment_params = payload.app_segment_params;
+      app_segment_params.payload = CreateJumbf(jumbf_params);
+
+      jumbf_boxes.push_back(app_segment_params);
+    }
+    if (input.modify_jumbf_boxes != nullptr) {
+      input.modify_jumbf_boxes(&jumbf_boxes);
+    }
+    return jumbf_boxes;
+  }
+
+  absl::StatusOr<JpegParams> PopulateAppSegments(
+      TestInput input, std::vector<AppSegmentParams> jumbf_boxes) {
+    JpegParams result = input.jpeg_params;
+
+    result.payload.clear();
+    result.payload.reserve(jumbf_boxes.size());
+
+    uint16_t en = 0;
+    for (auto& jumbf_box : jumbf_boxes) {
+      if (!jumbf_box.en.has_value()) {
+        jumbf_box.en = ++en;
+      }
+
+      ABSL_ASSIGN_OR_RETURN(auto segments, CreateAppSegments(jumbf_box));
+      for (const auto& segment : segments) {
+        result.payload.push_back(segment);
+      }
+    }
+
+    if (input.modify_app_segments != nullptr) {
+      input.modify_app_segments(&result);
+    }
+    return result;
+  }
+
+ protected:
+  absl::StatusOr<std::string> CreateImage(TestInput input) {
+    ABSL_ASSIGN_OR_RETURN(
+        JpegParams params,
+        PopulateAppSegments(input, PopulateJumbfBoxes(input)));
+    return CreateJpeg(params);
+  }
+};
+
+TEST_P(ExtractorTest, Extract) {
+  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
+  riegeli::StringReader input(image);
+
+  absl::StatusOr<std::string> extractor_result =
+      JpegExtractor().ExtractManifestStore(input);
+
+  if (GetParam().expected.ok()) {
+    ASSERT_OK_AND_ASSIGN(const std::string& manifest_store, extractor_result);
+    ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected);
+    EXPECT_THAT(manifest_store, Eq(expected.manifest_store));
+  } else {
+    EXPECT_THAT(extractor_result,
+                StatusIs(GetParam().expected.status().code(),
+                         HasSubstr(GetParam().expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) {
+  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
+  std::string contents = "padding" + image;
+  riegeli::StringReader input(contents);
+
+  ASSERT_TRUE(input.Seek(7));
+  absl::StatusOr<std::string> extractor_result =
+      JpegExtractor().ExtractManifestStore(input);
+
+  if (GetParam().expected.ok()) {
+    ASSERT_OK_AND_ASSIGN(const std::string& manifest_store, extractor_result);
+    ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected);
+    EXPECT_THAT(manifest_store, Eq(expected.manifest_store));
+  } else {
+    EXPECT_THAT(extractor_result,
+                StatusIs(GetParam().expected.status().code(),
+                         HasSubstr(GetParam().expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocation) {
+  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
+  riegeli::StringReader input(image);
+
+  absl::StatusOr<std::optional<ByteRange>> result =
+      JpegExtractor().ExtractManifestStoreLocation(input, {});
+
+  if (GetParam().expected.ok()) {
+    ASSERT_OK_AND_ASSIGN(auto manifest_store_location, result);
+    ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected);
+    EXPECT_THAT(manifest_store_location,
+                Eq(expected.asset_byte_info.manifest_store_location));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(GetParam().expected.status().code(),
+                         HasSubstr(GetParam().expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxes) {
+  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
+  riegeli::StringReader input(image);
+
+  absl::StatusOr<std::vector<AssetBox>> result =
+      JpegExtractor().ExtractBoxes(input, {});
+
+  if (GetParam().expected.ok()) {
+    ASSERT_OK_AND_ASSIGN(const auto& boxes, result);
+    ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected);
+    EXPECT_THAT(boxes, Eq(expected.asset_byte_info.boxes));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(GetParam().expected.status().code(),
+                         HasSubstr(GetParam().expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffix) {
+  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
+  std::string contents = image + "suffix";
+  riegeli::StringReader input(contents);
+
+  absl::StatusOr<std::optional<ByteRange>> result =
+      JpegExtractor().ExtractManifestStoreLocation(
+          input, {.end_offset = static_cast<int64_t>(contents.size() - 6)});
+
+  if (GetParam().expected.ok()) {
+    ASSERT_OK_AND_ASSIGN(auto manifest_store_location, result);
+    ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected);
+    EXPECT_THAT(manifest_store_location,
+                Eq(expected.asset_byte_info.manifest_store_location));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(GetParam().expected.status().code(),
+                         HasSubstr(GetParam().expected.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxesWithSuffix) {
+  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
+  std::string contents = image + "suffix";
+  riegeli::StringReader input(contents);
+
+  absl::StatusOr<std::vector<AssetBox>> result = JpegExtractor().ExtractBoxes(
+      input, {.end_offset = static_cast<int64_t>(contents.size()) - 6});
+
+  if (GetParam().expected.ok()) {
+    ASSERT_OK_AND_ASSIGN(const auto& boxes, result);
+    ASSERT_OK_AND_ASSIGN(const auto& expected, GetParam().expected);
+    EXPECT_THAT(boxes, Eq(expected.asset_byte_info.boxes));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(GetParam().expected.status().code(),
+                         HasSubstr(GetParam().expected.status().message())));
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    ImageEncoding, ExtractorTest,
+    testing::Values<TestInput>(
+        TestInput{
+            .name = "InvalidBeginning",
+            .payload = {},
+            .jpeg_params = {.start_marker = 0x0102},
+            .expected = absl::InvalidArgumentError("Invalid JPEG marker: 258"),
+        },
+        TestInput{
+            .name = "InvalidEnding",
+            .payload = {},
+            .jpeg_params = {.end_markers = 0x0304},
+            .expected = absl::InvalidArgumentError("Invalid JPEG marker: 772"),
+        },
+        TestInput{
+            .name = "NoSegments",
+            .payload = {},
+            .jpeg_params = {},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "NoApp11Segments",
+            .payload = {{.payload = "test",
+                         .app_segment_params = {.marker = 0xffe2}}},
+            .jpeg_params = {},
+            .expected = absl::NotFoundError("No manifest store found"),
+        }),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+INSTANTIATE_TEST_SUITE_P(
+    Segments, ExtractorTest,
+    testing::Values<TestInput>(
+        TestInput{
+            .name = "OnlyApp2Segment",
+            .payload = {{
+                .payload = "test",
+                .app_segment_params = {.marker = 0xffe2},
+            }},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "IncorrectJpegExtensionType",
+            .payload = {{
+                .payload = "test",
+                .app_segment_params = {.cl = 0101},
+            }},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "InvalidSegmentId",
+            .payload = {{
+                .payload = "test",
+                .app_segment_params = {.en = 0x0000},
+            }},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "SegmentsOutOfOrder",
+            .payload =
+                {
+                    {
+                        .payload =
+                            "This is a much longer string of test to ensure "
+                            "that there is enough data to expect a C2PA "
+                            "manifest store within the segment payload.",
+                        .app_segment_params = {.max_size = 150},
+                    },
+                },
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  ASSERT_EQ(params->payload.size(), 2);
+
+                  auto temp = params->payload[0];
+                  params->payload[0] = params->payload[1];
+                  params->payload[1] = temp;
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "IncompleteSegment",
+            .payload =
+                {
+                    {
+                        .payload = "longer test",
+                        .app_segment_params = {.max_size = 50},
+                    },
+                },
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  ASSERT_EQ(params->payload.size(), 2);
+                  params->payload.pop_back();
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "SplitSegment",
+            .payload =
+                {
+                    {
+                        .payload = "longer test",
+                        .app_segment_params = {.max_size = 50},
+                    },
+                    {
+                        .payload = "test",
+                        .app_segment_params = {.marker = 0xffe6},
+                    },
+                },
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  ASSERT_EQ(params->payload.size(), 3);
+                  auto temp = params->payload[1];
+                  params->payload[1] = params->payload[2];
+                  params->payload[2] = temp;
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        }),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+INSTANTIATE_TEST_SUITE_P(
+    JumbfBoxes, ExtractorTest,
+    testing::Values<TestInput>(
+        TestInput{
+            .name = "TooFewBytesToDetermineIfC2pa",
+            .payload = {{.payload = "test"}},
+            .modify_jumbf_boxes =
+                [](std::vector<AppSegmentParams>* params) {
+                  ASSERT_EQ(params->size(), 1);
+                  params->at(0).payload = params->at(0).payload.substr(0, 30);
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "IncorrectSuperBoxTbox",
+            .payload = {{
+                .payload = "test",
+                .jumbf_creator_params = {.super_box_identifier = 0x12345678},
+            }},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "InvalidUuidHigh",
+            .payload = {{
+                .payload = "test",
+                .jumbf_creator_params = {.uuid_high = 0x0000000000000000},
+            }},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "InvalidUuidLow",
+            .payload = {{
+                .payload = "test",
+                .jumbf_creator_params = {.uuid_low = 0x0000000000000000},
+            }},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "InvalidToggles",
+            .payload = {{
+                .payload = "test",
+                .jumbf_creator_params = {.toggles = 0b00110000},
+            }},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "InvalidLabel",
+            .payload = {{
+                .payload = "test",
+                .jumbf_creator_params = {.label = "demo"},
+            }},
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "ForceSizeIntoXlBox",
+            .payload = {{
+                .payload = "test",
+                .jumbf_creator_params = {.force_xlbox = true},
+            }},
+            .expected =
+                ExtractorResult{
+                    .manifest_store = CreateJumbf({.payload = "test",
+                                                   .force_xlbox = true}),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 2,
+                                                                 .length = 78},
+                            .boxes = CreateBoxes({
+                                {"SOI", /*size=*/2},
+                                {"C2PA", /*size=*/78},
+                                {"EOI", /*size=*/2},
+                            }),
+                        },
+                },
+        },
+        TestInput{
+            .name = "IncorrectLboxSize",
+            .payload = {{
+                .payload = "longer test",
+                .app_segment_params = {.max_size = 50},
+            }},
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  ASSERT_EQ(params->payload.size(), 2);
+                  params->payload.pop_back();
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "IncorrectXlboxSize",
+            .payload = {{
+                .payload = "This is a much longer string of test to ensure "
+                           "that there is enough data to expect a C2PA "
+                           "manifest store within the segment payload.",
+                .jumbf_creator_params = {.force_xlbox = true},
+                .app_segment_params = {.max_size = 100},
+            }},
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  ASSERT_EQ(params->payload.size(), 3);
+                  params->payload.pop_back();
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        }),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+INSTANTIATE_TEST_SUITE_P(
+    C2paManifestStore, ExtractorTest,
+    testing::Values<TestInput>(
+        TestInput{
+            .name = "OnlyManifestStore",
+            .payload = {{.payload = "test"}},
+            .expected =
+                ExtractorResult{
+                    .manifest_store = CreateJumbf({.payload = "test"}),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 2,
+                                                                 .length = 62},
+                            .boxes = CreateBoxes({
+                                {"SOI", /*size=*/2},
+                                {"C2PA", /*size=*/62},
+                                {"EOI", /*size=*/2},
+                            }),
+                        },
+                },
+        },
+        TestInput{
+            .name = "SandwichedManifestStore",
+            .payload =
+                {
+                    {
+                        .payload = "app3",
+                        .app_segment_params = {.marker = 0xffe3},
+                    },
+                    {.payload = "test"},
+                    {
+                        .payload = "app6",
+                        .app_segment_params = {.marker = 0xffe6},
+                    },
+                },
+            .expected =
+                ExtractorResult{
+                    .manifest_store = CreateJumbf({.payload = "test"}),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 64,
+                                                                 .length = 62},
+                            .boxes = CreateBoxes({
+                                {"SOI", /*size=*/2},
+                                {"APP3", /*size=*/62},
+                                {"C2PA", /*size=*/62},
+                                {"APP6", /*size=*/62},
+                                {"EOI", /*size=*/2},
+                            }),
+                        },
+                },
+        },
+        TestInput{
+            .name = "ManifestStoreAcrossTwoSegments",
+            .payload =
+                {
+                    {
+                        .payload = "longer test",
+                        .app_segment_params = {.max_size = 50},
+                    },
+                },
+            .expected =
+                ExtractorResult{
+                    .manifest_store = CreateJumbf({.payload = "longer test"}),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 2,
+                                                                 .length = 89},
+                            .boxes = CreateBoxes({
+                                {"SOI", /*size=*/2},
+                                {"C2PA", /*size=*/89},
+                                {"EOI", /*size=*/2},
+                            }),
+                        },
+                },
+        },
+        TestInput{
+            .name = "ManifestStoreAcrossTwoSegmentsDifferentTbox",
+            .payload =
+                {
+                    {
+                        .payload = "longer test",
+                        .app_segment_params = {.max_size = 50},
+                    },
+                },
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  ASSERT_EQ(params->payload.size(), 2);
+                  // lowest byte of tbox is at 19, normally 0x62, change it
+                  params->payload[1][19] = 0x69;
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "ManifestStoreAcrossTwoSegmentsDifferentLbox",
+            .payload =
+                {
+                    {
+                        .payload = "longer test",
+                        .app_segment_params = {.max_size = 50},
+                    },
+                },
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  ASSERT_EQ(params->payload.size(), 2);
+                  // lowest byte of lbox is at 15, normally 0x39, change it
+                  params->payload[1][15] = 0x47;
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "ManifestStoreAcrossTwoSegmentsDifferentXlbox",
+            .payload =
+                {
+                    {
+                        .payload = "longer test",
+                        .jumbf_creator_params = {.force_xlbox = true},
+                        .app_segment_params = {.max_size = 60},
+                    },
+                },
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  ASSERT_EQ(params->payload.size(), 2);
+                  // lowest byte of xlbox is at 27, normally 0x49, change it
+                  params->payload[1][27] = 0x57;
+                },
+            .expected = absl::NotFoundError("No manifest store found"),
+        },
+        TestInput{
+            .name = "TwoManifestStores",
+            .payload =
+                {
+                    {.payload = "store 1"},
+                    {.payload = "store 2"},
+                },
+            .expected = absl::NotFoundError("Multiple manifest stores found"),
+        },
+        TestInput{
+            .name = "TwoManifestStoresThenOthers",
+            .payload =
+                {{.payload = "store 1"},
+                 {.payload = "store 2"},
+                 {.payload = "app8", .app_segment_params = {.marker = 0xffe8}},
+                 {.payload = "app9", .app_segment_params = {.marker = 0xffe9}}},
+            .expected = absl::NotFoundError("Multiple manifest stores found"),
+        },
+        // If there are multiple manifest stores, but one of them is invalid
+        // JUMBF we should still return the valid one.
+        TestInput{
+            .name = "TwoManifestStoresOneInvalid",
+            .payload =
+                {
+                    {.payload = "store 1"},
+                    {
+                        .payload = "store 2",
+                        .app_segment_params = {.max_size = 50},
+                    },
+                },
+            .modify_app_segments =
+                [](JpegParams* params) {
+                  // Remove the last segment of the second manifest store to
+                  // invalidate it
+                  ASSERT_EQ(params->payload.size(), 3);
+                  params->payload.pop_back();
+                },
+            .expected =
+                ExtractorResult{
+                    .manifest_store = CreateJumbf({.payload = "store 1"}),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 2,
+                                                                 .length = 65},
+                            .boxes = CreateBoxes({
+                                {"SOI", /*size=*/2},
+                                {"C2PA", /*size=*/65},
+                                {"APP11", /*size=*/52},
+                                {"EOI", /*size=*/2},
+                            }),
+                        },
+                },
+        },
+        TestInput{
+            .name = "MultiSegmentC2paAndMultiSegmentMiscApp11",
+            .payload =
+                {
+                    {
+                        .payload =
+                            "This is a much longer string of test to ensure "
+                            "that there is enough data to expect a C2PA "
+                            "manifest store within the segment payload.",
+                        .app_segment_params = {.max_size = 100},
+                    },
+                    {
+                        .payload =
+                            "This is a much longer string of test to ensure "
+                            "that there is enough data to expect a C2PA "
+                            "manifest store within the segment payload.",
+                        .app_segment_params = {.cl = 0x1111, .max_size = 100},
+                    },
+                },
+            .expected =
+                ExtractorResult{
+                    .manifest_store = CreateJumbf(
+                        {.payload =
+                             "This is a much longer string of test to ensure "
+                             "that there is enough data to expect a C2PA "
+                             "manifest store within the segment payload."}),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 2,
+                                                                 .length = 230},
+                            .boxes = CreateBoxes({
+                                {"SOI", /*size=*/2},
+                                {"C2PA", /*size=*/230},
+                                {"APP11", /*size=*/102},
+                                {"APP11", /*size=*/102},
+                                {"APP11", /*size=*/26},
+                                {"EOI", /*size=*/2},
+                            }),
+                        },
+                },
+        }),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+TEST(JpegExtractorTest, IsManifestStorePayloadMissingJpegElements) {
+  EXPECT_FALSE(JpegExtractor().MightBeC2paManifestStore(
+      CreateStartOfManifestStorePayload()));
+}
+
+TEST(JpegExtractorTest, IsManifestStoreIncorrectExtensionType) {
+  EXPECT_FALSE(JpegExtractor().MightBeC2paManifestStore(
+      CreateStartOfManifestStorePayload(
+          {0x12, 0x34, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01})));
+}
+
+TEST(JpegExtractorTest, IsManifestStore) {
+  EXPECT_TRUE(JpegExtractor().MightBeC2paManifestStore(
+      CreateStartOfManifestStorePayload(
+          {0x4a, 0x50, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01})));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/format.cc b/formats/jpeg/format.cc
new file mode 100644
index 0000000..6fca0f0
--- /dev/null
+++ b/formats/jpeg/format.cc
@@ -0,0 +1,35 @@
+// 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 "formats/jpeg/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+#include "formats/jpeg/assessor.h"
+#include "formats/jpeg/extractor.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateJpegFormat() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<JpegAssessor>(),
+      .extractor = std::make_unique<JpegExtractor>(),
+      .mime_types = {"image/jpeg"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/format.h b/formats/jpeg/format.h
new file mode 100644
index 0000000..e27f39f
--- /dev/null
+++ b/formats/jpeg/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_JPEG_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateJpegFormat();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_FORMAT_H_
diff --git a/formats/jpeg/reader.cc b/formats/jpeg/reader.cc
new file mode 100644
index 0000000..1c17dd5
--- /dev/null
+++ b/formats/jpeg/reader.cc
@@ -0,0 +1,279 @@
+// 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 "formats/jpeg/reader.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <optional>
+#include <ostream>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "formats/jpeg/box_header.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+namespace {
+
+constexpr uint16_t kApp11ExtensionType = 0x4a50;
+
+// Returns the start of the next chunk following the scan data that starts at
+// `offset`.
+absl::StatusOr<int64_t> FindEndOfScan(riegeli::Reader& input, int64_t offset) {
+  if (!input.Seek(offset)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+
+  uint8_t first = 0;
+  uint8_t second = 0;
+  while (true) {
+    if (first == 0xff && second != 0) {
+      break;
+    }
+    first = second;
+    if (!input.ReadByte(second)) {
+      return input.StatusOrAnnotate(absl::DataLossError("Failed to read byte"));
+    }
+  }
+  return input.pos() - 2;  // Back up to start of chunk header.
+}
+
+absl::Status PopulateJumbfData(riegeli::Reader& reader, JpegSegment& segment) {
+  if (!segment.app11_info.has_value()) {
+    // Will never happen as it's always called from PopulateApp11Info
+    return absl::InvalidArgumentError(
+        "APP11 info not populated, cannot populate JUMBf data");
+  }
+
+  uint64_t header_size = 8;  // lbox + tbox
+  if (segment.app11_info->payload_length < header_size) {
+    // Not enough bytes for JUMBf header.
+    return absl::OkStatus();
+  }
+
+  segment.app11_info->jumbf_data = JpegJumbfData{};
+  if (!riegeli::ReadBigEndian<uint32_t>(reader,
+                                        segment.app11_info->jumbf_data->lbox)) {
+    return reader.StatusOrAnnotate(absl::DataLossError("Failed to read lbox"));
+  }
+  if (!riegeli::ReadBigEndian<uint32_t>(reader,
+                                        segment.app11_info->jumbf_data->tbox)) {
+    return reader.StatusOrAnnotate(absl::DataLossError("Failed to read tbox"));
+  }
+  if (segment.app11_info->jumbf_data->lbox == 1) {
+    header_size += sizeof(uint64_t);
+
+    if (segment.app11_info->payload_length < header_size) {
+      return absl::InvalidArgumentError(
+          "JUMBf box payload too short to contain xlbox");
+    }
+    if (!riegeli::ReadBigEndian<uint64_t>(
+            reader, segment.app11_info->jumbf_data->xlbox)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to read xlbox"));
+    }
+  }
+
+  uint64_t jumbf_size = segment.app11_info->jumbf_data->lbox == 1
+                            ? segment.app11_info->jumbf_data->xlbox
+                            : segment.app11_info->jumbf_data->lbox;
+  if (jumbf_size < header_size) {
+    return absl::InvalidArgumentError("JUMBf box payload too short");
+  }
+
+  segment.app11_info->jumbf_data->payload_offset =
+      segment.app11_info->payload_offset + header_size;
+  segment.app11_info->jumbf_data->payload_length =
+      segment.app11_info->payload_length - header_size;
+  return absl::OkStatus();
+}
+
+absl::Status PopulateApp11Info(riegeli::Reader& reader, JpegSegment& segment) {
+  // APP11 segment payload must be at least 8 bytes long
+  if (segment.payload_length < 8) {
+    // Return OK without populating app11_info, treating it as non-C2PA.
+    return absl::OkStatus();
+  }
+
+  // Seek to the start of the APP11 header.
+  if (!reader.Seek(segment.payload_offset)) {
+    return reader.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+
+  segment.app11_info = JpegApp11Segment{};
+  if (!riegeli::ReadBigEndian<uint16_t>(reader,
+                                        segment.app11_info->extension_type)) {
+    return reader.StatusOrAnnotate(
+        absl::DataLossError("Failed to read extension type"));
+  }
+  if (!riegeli::ReadBigEndian<uint16_t>(reader,
+                                        segment.app11_info->segment_id)) {
+    return reader.StatusOrAnnotate(
+        absl::DataLossError("Failed to read segment ID"));
+  }
+  if (!riegeli::ReadBigEndian<uint32_t>(reader,
+                                        segment.app11_info->sequence_number)) {
+    return reader.StatusOrAnnotate(
+        absl::DataLossError("Failed to read sequence number"));
+  }
+
+  segment.app11_info->payload_offset = segment.payload_offset + 8;
+  segment.app11_info->payload_length = segment.payload_length - 8;
+
+  if (segment.app11_info->extension_type == kApp11ExtensionType) {
+    return PopulateJumbfData(reader, segment);
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::Status IterateOverJpegSegments(riegeli::Reader& reader,
+                                     JpegSegmentProcessor processor,
+                                     int64_t end_offset) {
+  uint64_t current_offset = reader.pos();
+  uint64_t actual_end_offset =
+      end_offset == -1
+          ? reader.Size().value_or(0)
+          : std::min(static_cast<uint64_t>(reader.Size().value_or(0)),
+                     static_cast<uint64_t>(end_offset));
+
+  while (current_offset < actual_end_offset) {
+    if (!reader.Seek(current_offset)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek to offset"));
+    }
+
+    JpegBoxHeader box_header;
+    if (auto result = ConsumeJpegBoxHeader(reader); result.ok()) {
+      box_header = *result;
+    } else {
+      return result.status();
+    }
+
+    JpegSegment segment = {
+        .offset = box_header.offset,
+        .marker = box_header.type,
+        .marker_label = box_header.label(),
+        .length = box_header.size,
+        .payload_offset = box_header.size < 4
+                              ? box_header.offset + box_header.size
+                              : box_header.offset + 4,
+        .payload_length = box_header.size < 4 ? 0 : box_header.size - 4,
+    };
+
+    if (segment.marker_label == "SOS" ||
+        absl::StartsWith(segment.marker_label, "RST")) {
+      // Chunk is followed by indefinite-length image data, which is
+      // included in the box content for hashing purposes.
+      uint64_t next_chunk_start = 0;
+      if (auto result = FindEndOfScan(reader, segment.offset + segment.length);
+          result.ok()) {
+        next_chunk_start = *result;
+      } else {
+        return result.status();
+      }
+      segment.length = next_chunk_start - segment.offset;
+      segment.payload_length = next_chunk_start - segment.payload_offset;
+      // Return to the start of the payload.
+      if (!reader.Seek(segment.payload_offset)) {
+        return reader.StatusOrAnnotate(
+            absl::DataLossError("Failed to seek to offset"));
+      }
+    }
+
+    if (segment.marker_label == "APP11") {
+      ABSL_RETURN_IF_ERROR(PopulateApp11Info(reader, segment));
+    }
+
+    if (segment.length > actual_end_offset - segment.offset &&
+        segment.payload_length > 0) {
+      // This box is truncated.
+      return absl::InvalidArgumentError("truncated JPEG box");
+    }
+    current_offset = segment.offset + segment.length;
+
+    bool continue_processing = true;
+    if (auto result = processor(segment); result.ok()) {
+      continue_processing = *result;
+    } else {
+      return result.status();
+    }
+    if (!continue_processing) {
+      return absl::OkStatus();
+    }
+
+    if (segment.marker_label == "EOI") {
+      break;
+    }
+  }
+
+  if (current_offset < actual_end_offset) {
+    // End of first image, any additional data is just a `c2pa.after` segment.
+    JpegSegment after_segment =
+        JpegSegment{.offset = current_offset,
+                    .marker = 0x0000,
+                    .marker_label = "c2pa.after",
+                    .length = actual_end_offset - current_offset,
+                    .payload_offset = 0,
+                    .payload_length = 0};
+    return processor(after_segment).status();
+  }
+
+  return absl::OkStatus();
+}
+
+void PrintTo(const JpegJumbfData& x, ::std::ostream* os) {
+  *os << absl::StrCat("{lbox: ", x.lbox, ", tbox: ", x.tbox,
+                      ", xlbox: ", x.xlbox,
+                      ", payload_offset: ", x.payload_offset,
+                      ", payload_length: ", x.payload_length, "}");
+}
+
+void PrintTo(const JpegApp11Segment& x, ::std::ostream* os) {
+  *os << "{extension_type: " << x.extension_type
+      << ", segment_id: " << x.segment_id
+      << ", sequence_number: " << x.sequence_number
+      << ", payload_offset: " << x.payload_offset
+      << ", payload_length: " << x.payload_length << ", jumbf_data: ";
+  if (x.jumbf_data.has_value()) {
+    PrintTo(*x.jumbf_data, os);
+  } else {
+    *os << "nullopt";
+  }
+  *os << "}";
+}
+
+void PrintTo(const JpegSegment& x, ::std::ostream* os) {
+  *os << "{offset: " << x.offset << ", marker: " << x.marker
+      << ", marker_label: \"" << x.marker_label << "\""
+      << ", length: " << x.length << ", payload_offset: " << x.payload_offset
+      << ", payload_length: " << x.payload_length << ", app11_info: ";
+  if (x.app11_info.has_value()) {
+    PrintTo(*x.app11_info, os);
+  } else {
+    *os << "nullopt";
+  }
+  *os << "}";
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/reader.h b/formats/jpeg/reader.h
new file mode 100644
index 0000000..09672ff
--- /dev/null
+++ b/formats/jpeg/reader.h
@@ -0,0 +1,93 @@
+// 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_FORMATS_JPEG_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_READER_H_
+
+#include <cstdint>
+#include <iosfwd>
+#include <optional>
+#include <string>
+
+#include "absl/functional/function_ref.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+struct JpegJumbfData {
+  // Offset for this data is contained within
+  // `JpegApp11Segment::payload_offset`.
+  uint32_t lbox;
+  uint32_t tbox;
+  uint64_t xlbox;
+
+  // Offset and length of the data contained in the JUMBF segment.
+  uint64_t payload_offset;
+  uint64_t payload_length;
+
+  friend bool operator==(const JpegJumbfData&, const JpegJumbfData&) = default;
+};
+
+void PrintTo(const JpegJumbfData& x, ::std::ostream* os);
+
+struct JpegApp11Segment {
+  uint16_t extension_type;
+  uint16_t segment_id;
+  uint32_t sequence_number;
+
+  // Offset and length of the data contained in the APP11 segment.
+  uint64_t payload_offset;
+  uint64_t payload_length;
+
+  std::optional<JpegJumbfData> jumbf_data;
+
+  friend bool operator==(const JpegApp11Segment&,
+                         const JpegApp11Segment&) = default;
+};
+
+void PrintTo(const JpegApp11Segment& x, ::std::ostream* os);
+
+struct JpegSegment {
+  uint64_t offset;
+  uint16_t marker;           // Marker code for the segment.
+  std::string marker_label;  // Human-readable label for the marker.
+  uint64_t length;
+
+  // Offset and length of the data contained in the segment.
+  uint64_t payload_offset;
+  uint64_t payload_length;
+
+  std::optional<JpegApp11Segment> app11_info;
+
+  friend bool operator==(const JpegSegment&, const JpegSegment&) = default;
+};
+
+void PrintTo(const JpegSegment& x, ::std::ostream* os);
+
+using JpegSegmentProcessor =
+    absl::FunctionRef<absl::StatusOr<bool>(const JpegSegment&)>;
+
+// Iterates over all JPEG segments in the given reader, calling the given
+// function for each segment. The method will NOT seek to position 0 in the
+// reader before iterating, it will start from the current position.
+absl::Status IterateOverJpegSegments(riegeli::Reader& reader,
+                                     JpegSegmentProcessor processor,
+                                     int64_t end_offset = -1);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_READER_H_
diff --git a/formats/jpeg/reader_test.cc b/formats/jpeg/reader_test.cc
new file mode 100644
index 0000000..312e58b
--- /dev/null
+++ b/formats/jpeg/reader_test.cc
@@ -0,0 +1,434 @@
+// 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 "formats/jpeg/reader.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+using ::testing::PrintToString;
+using ::testing::SizeIs;
+
+class MarkerTest : public testing::TestWithParam<uint16_t> {};
+
+INSTANTIATE_TEST_SUITE_P(MarkerBasedTests, MarkerTest,
+                         ::testing::Range(uint16_t{0xff00}, uint16_t{0xffff}),
+                         [](const testing::TestParamInfo<uint16_t>& marker) {
+                           return absl::StrFormat("%04x", marker.param);
+                         });
+
+TEST_P(MarkerTest, HandlesMarker) {
+  std::string data("\0\0\0\4\1\1\2\2\1\1\1\1\1\1\1\1", 16);
+  data[0] = static_cast<uint8_t>((GetParam() & 0x0000ff00) >> 8);
+  data[1] = static_cast<uint8_t>((GetParam() & 0x000000ff));
+  data[6] = 0xff;
+  data[7] = 0xff;
+
+  riegeli::StringReader<> input(data);
+
+  JpegSegment segment;
+  bool called = false;
+  EXPECT_THAT(
+      IterateOverJpegSegments(input,
+                              [&segment, &called](const JpegSegment& chunk) {
+                                segment = std::move(chunk);
+                                called = true;
+                                return false;  // stop after first
+                              }),
+      IsOk());
+  ASSERT_TRUE(called);
+
+  EXPECT_EQ(segment.marker, GetParam());
+
+  EXPECT_EQ(segment.offset, 0);
+
+  if (GetParam() == 0xff01 || GetParam() == 0xffd8 || GetParam() == 0xffd9) {
+    // No payload.
+    EXPECT_EQ(segment.length, 2);
+    EXPECT_EQ(segment.payload_offset, 2);
+    EXPECT_EQ(segment.payload_length, 0);
+    return;
+  }
+
+  EXPECT_EQ(segment.length, 6);
+
+  if ((GetParam() >= 0xffd0 && GetParam() <= 0xffd7)) {
+    // No defined length, so length bytes are part of payload
+    EXPECT_EQ(segment.payload_offset, 2);
+    EXPECT_EQ(segment.payload_length, 4);
+    return;
+  }
+
+  EXPECT_EQ(segment.payload_offset, 4);
+  EXPECT_EQ(segment.payload_length, 2);
+}
+
+TEST(ReaderTest, IterateOverMultipleSegments) {
+  // SOI (2 bytes), APP0 (6 bytes with 2 bytes payload), EOI (2 bytes)
+  std::string data("\xff\xd8\xff\xe0\x00\x04\x01\x02\xff\xd9", 10);
+  riegeli::StringReader<> input(data);
+
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;  // continue
+                                      }),
+              IsOk());
+
+  // SOI is processed, APP0 is processed, EOI is processed (and breaks the loop)
+  ASSERT_EQ(segments.size(), 3);
+  EXPECT_EQ(segments[0].marker, 0xffd8);
+  EXPECT_EQ(segments[0].marker_label, "SOI");
+  EXPECT_EQ(segments[1].marker, 0xffe0);
+  EXPECT_EQ(segments[1].marker_label, "APP0");
+  EXPECT_EQ(segments[2].marker, 0xffd9);
+  EXPECT_EQ(segments[2].marker_label, "EOI");
+}
+
+TEST(ReaderTest, ParsesValidApp11Segment) {
+  std::string data(
+      "\xff\xd8"                          // SOI
+      "\xff\xeb\x00\x12"                  // APP11, length 18
+      "\x4a\x50\x00\x01\x00\x00\x00\x01"  // APP11 header (type 0x4a50, id 1,
+                                          // seq 1)
+      "\x00\x00\x00\x08\x6a\x75\x6d\x62"  // JUMBF header (lbox 8, tbox 'jumb')
+      "\xff\xd9",                         // EOI
+      24);
+  riegeli::StringReader<> input(data);
+
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              IsOk());
+
+  ASSERT_EQ(segments.size(), 3);
+  EXPECT_EQ(segments[1].marker, 0xffeb);
+  EXPECT_EQ(segments[1].marker_label, "APP11");
+  ASSERT_TRUE(segments[1].app11_info.has_value());
+  EXPECT_EQ(segments[1].app11_info->extension_type, 0x4a50);
+  EXPECT_EQ(segments[1].app11_info->segment_id, 1);
+  EXPECT_EQ(segments[1].app11_info->sequence_number, 1);
+  ASSERT_TRUE(segments[1].app11_info->jumbf_data.has_value());
+  EXPECT_EQ(segments[1].app11_info->jumbf_data->lbox, 8);
+  EXPECT_EQ(segments[1].app11_info->jumbf_data->tbox, 0x6a756d62);
+  EXPECT_EQ(segments[1].app11_info->jumbf_data->xlbox, 0);
+}
+
+TEST(ReaderTest, HandlesTruncatedBox) {
+  // SOI (2 bytes), APP0 (18 bytes total: marker 2, length 2, payload 14)
+  std::string data(
+      "\xff\xd8"
+      "\xff\xe0\x00\x10\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d"
+      "\x0e",
+      20);
+  riegeli::StringReader<> input(data);
+
+  // We pass end_offset = 15, which truncates the APP0 box.
+  EXPECT_THAT(
+      IterateOverJpegSegments(
+          input, [](const JpegSegment& chunk) { return true; }, 15),
+      StatusIs(absl::StatusCode::kInvalidArgument, "truncated JPEG box"));
+}
+
+TEST(ReaderTest, ParsesSosWithByteStuffing) {
+  std::string data(
+      "\xff\xd8"                          // SOI
+      "\xff\xda\x00\x06\xaa\xbb\xcc\xdd"  // SOS header (size 8)
+      "\x11\x22\xff\x00\x33\x44"  // Scan data (6 bytes, containing stuffed
+                                  // \xff\x00)
+      "\xff\xd9",                 // EOI
+      18);
+  riegeli::StringReader<> input(data);
+
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              IsOk());
+
+  ASSERT_EQ(segments.size(), 3);
+  EXPECT_EQ(segments[1].marker, 0xffda);
+  EXPECT_EQ(segments[1].marker_label, "SOS");
+  EXPECT_EQ(segments[1].offset, 2);
+  EXPECT_EQ(segments[1].length, 14);  // 8 (header) + 6 (scan data)
+  EXPECT_EQ(segments[1].payload_offset, 6);
+  EXPECT_EQ(segments[1].payload_length, 10);
+
+  EXPECT_EQ(segments[2].marker, 0xffd9);
+  EXPECT_EQ(segments[2].marker_label, "EOI");
+  EXPECT_EQ(segments[2].offset, 16);
+}
+
+TEST(ReaderTest, ParsesC2paAfterSegment) {
+  std::string data(
+      "\xff\xd8"           // SOI
+      "\xff\xd9"           // EOI
+      "\x01\x02\x03\x04",  // Extra data
+      8);
+  riegeli::StringReader<> input(data);
+
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              IsOk());
+
+  ASSERT_EQ(segments.size(), 3);
+  EXPECT_EQ(segments[0].marker, 0xffd8);
+  EXPECT_EQ(segments[1].marker, 0xffd9);
+
+  EXPECT_EQ(segments[2].marker, 0x0000);
+  EXPECT_EQ(segments[2].marker_label, "c2pa.after");
+  EXPECT_EQ(segments[2].offset, 4);
+  EXPECT_EQ(segments[2].length, 4);
+  EXPECT_EQ(segments[2].payload_offset, 0);
+  EXPECT_EQ(segments[2].payload_length, 0);
+}
+
+TEST(ReaderTest, PropagatesProcessorError) {
+  std::string data("\xff\xd8\xff\xe0\x00\x04\x01\x02\xff\xd9", 10);
+  riegeli::StringReader<> input(data);
+
+  EXPECT_THAT(
+      IterateOverJpegSegments(input,
+                              [](const JpegSegment& chunk) {
+                                return absl::InternalError("test error");
+                              }),
+      StatusIs(absl::StatusCode::kInternal, "test error"));
+}
+
+TEST(ReaderTest, PrintToJpegJumbfData) {
+  JpegJumbfData data = {.lbox = 1,
+                        .tbox = 2,
+                        .xlbox = 3,
+                        .payload_offset = 4,
+                        .payload_length = 5};
+  EXPECT_EQ(
+      PrintToString(data),
+      "{lbox: 1, tbox: 2, xlbox: 3, payload_offset: 4, payload_length: 5}");
+}
+
+TEST(ReaderTest, PrintToJpegApp11Segment) {
+  JpegApp11Segment segment = {
+      .extension_type = 1,
+      .segment_id = 2,
+      .sequence_number = 3,
+      .payload_offset = 4,
+      .payload_length = 5,
+      .jumbf_data = JpegJumbfData{.lbox = 10,
+                                  .tbox = 20,
+                                  .xlbox = 30,
+                                  .payload_offset = 40,
+                                  .payload_length = 50}};
+
+  EXPECT_EQ(PrintToString(segment),
+            "{extension_type: 1, segment_id: 2, sequence_number: 3, "
+            "payload_offset: 4, payload_length: 5, jumbf_data: {lbox: 10, "
+            "tbox: 20, xlbox: 30, payload_offset: 40, payload_length: 50}}");
+
+  JpegApp11Segment segment_no_jumbf = {.extension_type = 1,
+                                       .segment_id = 2,
+                                       .sequence_number = 3,
+                                       .payload_offset = 4,
+                                       .payload_length = 5,
+                                       .jumbf_data = std::nullopt};
+  EXPECT_EQ(PrintToString(segment_no_jumbf),
+            "{extension_type: 1, segment_id: 2, sequence_number: 3, "
+            "payload_offset: 4, payload_length: 5, jumbf_data: nullopt}");
+}
+
+TEST(ReaderTest, PrintToJpegSegment) {
+  JpegSegment segment = {
+      .offset = 1,
+      .marker = 2,
+      .marker_label = "LABEL",
+      .length = 3,
+      .payload_offset = 4,
+      .payload_length = 5,
+      .app11_info = JpegApp11Segment{.extension_type = 10,
+                                     .segment_id = 20,
+                                     .sequence_number = 30,
+                                     .payload_offset = 40,
+                                     .payload_length = 50,
+                                     .jumbf_data = std::nullopt}};
+
+  EXPECT_EQ(PrintToString(segment),
+            "{offset: 1, marker: 2, marker_label: \"LABEL\", length: 3, "
+            "payload_offset: 4, payload_length: 5, app11_info: "
+            "{extension_type: 10, segment_id: 20, sequence_number: 30, "
+            "payload_offset: 40, payload_length: 50, jumbf_data: nullopt}}");
+
+  JpegSegment segment_no_app11 = {.offset = 1,
+                                  .marker = 2,
+                                  .marker_label = "LABEL",
+                                  .length = 3,
+                                  .payload_offset = 4,
+                                  .payload_length = 5,
+                                  .app11_info = std::nullopt};
+  EXPECT_EQ(PrintToString(segment_no_app11),
+            "{offset: 1, marker: 2, marker_label: \"LABEL\", length: 3, "
+            "payload_offset: 4, payload_length: 5, app11_info: nullopt}");
+}
+
+TEST(ReaderTest, MalformedSegmentSize) {
+  std::string data("\xff\xd8\xff\xe0\x00\x00\xff\xd9", 8);
+  riegeli::StringReader<> input(data);
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Invalid JPEG segment size: 0")));
+}
+
+TEST(ReaderTest, App11SegmentShortLengthDesynchronization) {
+  std::string data(
+      "\xff\xd8"
+      "\xff\xeb\x00\x04\x00\x00"
+      "\xff\xeb\x00\x12\x4a\x50\x00\x01\x00\x00\x00\x01\x00\x00\x00\x08\x6a\x75"
+      "\x6d\x62"
+      "\xff\xd9",
+      30);
+  riegeli::StringReader<> input(data);
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              IsOk());
+  ASSERT_EQ(segments.size(), 4);
+  EXPECT_EQ(segments[0].marker_label, "SOI");
+  EXPECT_EQ(segments[1].marker, 0xffeb);
+  EXPECT_EQ(segments[1].length, 6);
+  EXPECT_FALSE(segments[1].app11_info.has_value());
+  EXPECT_EQ(segments[2].marker, 0xffeb);
+  EXPECT_EQ(segments[2].offset, 8);
+  EXPECT_EQ(segments[2].length, 20);
+  EXPECT_TRUE(segments[2].app11_info.has_value());
+  EXPECT_EQ(segments[3].marker_label, "EOI");
+}
+
+TEST(ReaderTest, App11JumbfBoxXlboxTooShort) {
+  std::string data(
+      "\xff\xd8"
+      "\xff\xeb\x00\x1c\x4a\x50\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x6a\x75"
+      "\x6d\x62\x00\x00\x00\x00\x00\x00\x00\x0a\x00\x00"
+      "\xff\xd9",
+      34);
+  riegeli::StringReader<> input(data);
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("JUMBf box payload too short")));
+}
+
+TEST(ReaderTest, App11JumbfBoxLboxReserved) {
+  std::string data(
+      "\xff\xd8"
+      "\xff\xeb\x00\x12\x4a\x50\x00\x01\x00\x00\x00\x01\x00\x00\x00\x05\x6a\x75"
+      "\x6d\x62"
+      "\xff\xd9",
+      24);
+  riegeli::StringReader<> input(data);
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("JUMBf box payload too short")));
+}
+
+TEST(ReaderTest, App11JumbfBoxPayloadLengthTooShort) {
+  // SOI (2 bytes)
+  // APP11 (16 bytes)
+  //   - header (4 bytes)
+  //   - extension info (8 bytes)
+  //   - JUMBF header (4 bytes) * Invalid: JUMBF needs a minimum of 8 bytes
+  // EOI (2 bytes)
+  std::string data(
+      "\xff\xd8"
+      "\xff\xeb\x00\x0e\x4a\x50\x00\x01\x00\x00\x00\x01\x00\x00\x00\x08"
+      "\xff\xd9",
+      20);
+  riegeli::StringReader<> input(data);
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              IsOk());
+  EXPECT_THAT(segments, SizeIs(3));
+  EXPECT_EQ(segments[0].marker_label, "SOI");
+  EXPECT_EQ(segments[1].marker_label, "APP11");
+  EXPECT_TRUE(segments[1].app11_info.has_value());
+  EXPECT_FALSE(segments[1].app11_info->jumbf_data.has_value());
+  EXPECT_EQ(segments[2].marker_label, "EOI");
+}
+
+TEST(ReaderTest, PayloadLengthDoesNotUnderflowOnShortSegment) {
+  // SOI (2 bytes: \xff\xd8) followed by EOI (\xff\xd9)
+  std::string data("\xff\xd8\xff\xd9", 4);
+  riegeli::StringReader<> input(data);
+  std::vector<JpegSegment> segments;
+  EXPECT_THAT(IterateOverJpegSegments(input,
+                                      [&segments](const JpegSegment& chunk) {
+                                        segments.push_back(chunk);
+                                        return true;
+                                      }),
+              IsOk());
+  ASSERT_GE(segments.size(), 1);
+  EXPECT_EQ(segments[0].marker, 0xffd8);
+  EXPECT_EQ(segments[0].payload_length, 0);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/testing/BUILD b/formats/jpeg/testing/BUILD
new file mode 100644
index 0000000..671f516
--- /dev/null
+++ b/formats/jpeg/testing/BUILD
@@ -0,0 +1,64 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(
+    default_testonly = True,
+    default_visibility = ["//visibility:public"],
+)
+
+cc_library(
+    name = "jumbf_creator",
+    srcs = ["jumbf_creator.cc"],
+    hdrs = ["jumbf_creator.h"],
+    deps = [
+        "//utils:byte_writers",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "app_segment_creator",
+    srcs = ["app_segment_creator.cc"],
+    hdrs = ["app_segment_creator.h"],
+    deps = [
+        "//utils:byte_readers",
+        "//utils:byte_writers",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "jpeg_creator",
+    srcs = ["jpeg_creator.cc"],
+    hdrs = ["jpeg_creator.h"],
+    deps = ["//utils:byte_writers"],
+)
+
+cc_test(
+    name = "app_segment_creator_test",
+    srcs = ["app_segment_creator_test.cc"],
+    deps = [
+        ":app_segment_creator",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/formats/jpeg/testing/app_segment_creator.cc b/formats/jpeg/testing/app_segment_creator.cc
new file mode 100644
index 0000000..6536640
--- /dev/null
+++ b/formats/jpeg/testing/app_segment_creator.cc
@@ -0,0 +1,112 @@
+// 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 "formats/jpeg/testing/app_segment_creator.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "utils/byte_readers.h"
+#include "utils/byte_writers.h"
+
+namespace credentio {
+
+absl::StatusOr<std::vector<std::string>> CreateAppSegments(
+    AppSegmentParams params) {
+  absl::string_view payload(params.payload);
+
+  std::vector<std::string> segments;
+
+  ABSL_ASSIGN_OR_RETURN(uint32_t lbox, ConsumeUint<uint32_t>(&payload));
+  ABSL_ASSIGN_OR_RETURN(uint32_t tbox, ConsumeUint<uint32_t>(&payload));
+  uint64_t xlbox = 0;
+  if (lbox == 1) {
+    ABSL_ASSIGN_OR_RETURN(xlbox, ConsumeUint<uint64_t>(&payload));
+  }
+
+  if (!params.en.has_value()) {
+    params.en = 1;
+  }
+
+  uint32_t z = params.starting_z;
+  uint16_t base_le = sizeof(uint16_t) +                   // le
+                     sizeof(uint16_t) +                   // cl
+                     sizeof(uint16_t) +                   // en
+                     sizeof(uint32_t) +                   // z
+                     sizeof(uint32_t) +                   // lbox
+                     sizeof(uint32_t) +                   // tbox
+                     (lbox == 1 ? sizeof(uint64_t) : 0);  // xlbox
+
+  if (params.max_size <= base_le) {
+    return absl::InvalidArgumentError(
+        "AppSegmentParams max_size must be strictly greater than base_le.");
+  }
+
+  uint16_t max_segment_size = params.max_size - base_le;
+  while (!payload.empty()) {
+    std::vector<uint8_t> bytes;
+    // The maximum size of the segment is params.max_size. The bytes vector
+    // also includes the 2-byte marker.
+    bytes.reserve(sizeof(uint16_t) + params.max_size);
+
+    // Add Marker
+    WriteUint16NetworkOrder(params.marker, &bytes);
+
+    uint16_t le =
+        base_le + (payload.length() < max_segment_size ? payload.length()
+                                                       : max_segment_size);
+    // Add Size
+    WriteUint16NetworkOrder(le, &bytes);
+
+    // Add Extension Type
+    WriteUint16NetworkOrder(params.cl, &bytes);
+
+    // Add Identifier
+    WriteUint16NetworkOrder(*params.en, &bytes);
+
+    // Add Sequence Number
+    WriteUint32NetworkOrder(z, &bytes);
+
+    // Add LBox
+    WriteUint32NetworkOrder(lbox, &bytes);
+
+    // Add TBox
+    WriteUint32NetworkOrder(tbox, &bytes);
+
+    // Maybe Add XLBox
+    if (lbox == 1) {
+      WriteUint64NetworkOrder(xlbox, &bytes);
+    }
+
+    for (int i = 0; i < max_segment_size && !payload.empty(); ++i) {
+      // Will Always Contain at least one byte
+      ABSL_ASSIGN_OR_RETURN(auto byte, ConsumeUint<uint8_t>(&payload));
+      bytes.push_back(byte);
+    }
+
+    z = params.z_incrementer(z);
+
+    segments.push_back(std::string(bytes.begin(), bytes.end()));
+  }
+
+  return segments;
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/testing/app_segment_creator.h b/formats/jpeg/testing/app_segment_creator.h
new file mode 100644
index 0000000..684213e
--- /dev/null
+++ b/formats/jpeg/testing/app_segment_creator.h
@@ -0,0 +1,53 @@
+// 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_FORMATS_JPEG_TESTING_APP_SEGMENT_CREATOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_APP_SEGMENT_CREATOR_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+
+namespace credentio {
+
+struct AppSegmentParams {
+  uint16_t marker = 0xffeb;                   // APP11 Marker
+  uint16_t cl = 0x4a50;                       // APP11 JPEG Extension Indicator
+  std::optional<uint16_t> en = std::nullopt;  // En: Instance Number
+
+  // Maximum size of the segment in bytes
+  uint32_t max_size = 65535;
+
+  // Z is the sequence number of the segment starting at the speficied value and
+  // using the z_incrementer function to increment
+  uint32_t starting_z = 1;
+  uint32_t (*z_incrementer)(uint32_t) = [](uint32_t z) { return z + 1; };
+
+  // The data to be embedded, potentially split into multiple segments
+  std::string payload;
+};
+
+// Creates Jpeg APP segments they are APP11 segments by default but can be
+// overridden by setting the various fields within the AppSegmentParams
+// See ISO/IEC 18477-3:2023 for the specification of APP segments
+absl::StatusOr<std::vector<std::string>> CreateAppSegments(
+    AppSegmentParams params);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_APP_SEGMENT_CREATOR_H_
diff --git a/formats/jpeg/testing/app_segment_creator_test.cc b/formats/jpeg/testing/app_segment_creator_test.cc
new file mode 100644
index 0000000..2250aee
--- /dev/null
+++ b/formats/jpeg/testing/app_segment_creator_test.cc
@@ -0,0 +1,56 @@
+// 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 "formats/jpeg/testing/app_segment_creator.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+
+TEST(AppSegmentCreatorTest, CreateAppSegmentsSuccess) {
+  AppSegmentParams params;
+  // 4 bytes lbox (10), 4 bytes tbox (0x4a554d42), 2 bytes data
+  params.payload = std::string("\x00\x00\x00\x0a\x4a\x55\x4d\x42\x01\x02", 10);
+  params.max_size = 65535;
+
+  auto segments_or = CreateAppSegments(params);
+  ASSERT_THAT(segments_or, IsOk());
+  EXPECT_EQ(segments_or.value().size(), 1);
+}
+
+TEST(AppSegmentCreatorTest, MaxSizeTooSmallReturnsError) {
+  AppSegmentParams params;
+  // 4 bytes lbox (10), 4 bytes tbox (0x4a554d42), 1 byte data
+  params.payload = std::string("\x00\x00\x00\x0a\x4a\x55\x4d\x42\x01", 9);
+  // base_le is 18 when lbox != 1. Setting max_size to 18 should trigger an
+  // error.
+  params.max_size = 18;
+
+  EXPECT_THAT(CreateAppSegments(params),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/testing/jpeg_creator.cc b/formats/jpeg/testing/jpeg_creator.cc
new file mode 100644
index 0000000..0eff735
--- /dev/null
+++ b/formats/jpeg/testing/jpeg_creator.cc
@@ -0,0 +1,42 @@
+// 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 "formats/jpeg/testing/jpeg_creator.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "utils/byte_writers.h"
+
+namespace credentio {
+
+std::string CreateJpeg(const JpegParams& params) {
+  std::vector<uint8_t> jpeg;
+
+  WriteUint16NetworkOrder(params.start_marker, &jpeg);
+
+  for (const auto& p : params.payload) {
+    for (const auto& c : p) {
+      jpeg.push_back(c);
+    }
+  }
+
+  WriteUint16NetworkOrder(params.end_markers, &jpeg);
+
+  return std::string(jpeg.begin(), jpeg.end());
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/testing/jpeg_creator.h b/formats/jpeg/testing/jpeg_creator.h
new file mode 100644
index 0000000..0e55f0e
--- /dev/null
+++ b/formats/jpeg/testing/jpeg_creator.h
@@ -0,0 +1,36 @@
+// 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_FORMATS_JPEG_TESTING_JPEG_CREATOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JPEG_CREATOR_H_
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace credentio {
+
+struct JpegParams {
+  uint16_t start_marker = 0xffd8;
+  uint16_t end_markers = 0xffd9;
+
+  std::vector<std::string> payload;
+};
+
+std::string CreateJpeg(const JpegParams& params);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JPEG_CREATOR_H_
diff --git a/formats/jpeg/testing/jumbf_creator.cc b/formats/jpeg/testing/jumbf_creator.cc
new file mode 100644
index 0000000..62d68d8
--- /dev/null
+++ b/formats/jpeg/testing/jumbf_creator.cc
@@ -0,0 +1,157 @@
+// 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 "formats/jpeg/testing/jumbf_creator.h"
+
+#include <sys/stat.h>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/strings/string_view.h"
+#include "utils/byte_writers.h"
+
+namespace credentio {
+namespace {
+
+std::string CreateJumbfDescriptionBox(const JumbfCreatorParams& params) {
+  std::vector<uint8_t> box;
+
+  // 4 bytes for Lbox
+  // 4 bytes for Tbox
+  // Maybe 8 bytes for XLBox (if forced)
+  // 8 bytes for UUID High
+  // 8 bytes for UUID Low
+  // 1 byte for Toggles
+  // X bytes for Label
+  // 1 byte for 0x00 terminator
+  // ---
+  // 26 + label.length() + 8?
+  uint32_t size = 26 + params.label.length() + (params.force_xlbox ? 8 : 0);
+  box.reserve(size);
+
+  uint32_t lbox = size;
+  std::optional<uint64_t> xlbox = std::nullopt;
+  if (params.force_xlbox) {
+    lbox = 1;
+    xlbox = static_cast<uint64_t>(size);
+  }
+
+  // Lbox
+  WriteUint32NetworkOrder(lbox, &box);
+
+  // Tbox
+  WriteUint32NetworkOrder(params.description_box_identifier, &box);
+
+  // Xlbox
+  if (xlbox.has_value()) {
+    WriteUint64NetworkOrder(*xlbox, &box);
+  }
+
+  // Type
+  WriteUint64NetworkOrder(params.uuid_high, &box);
+  WriteUint64NetworkOrder(params.uuid_low, &box);
+
+  // Toggles
+  box.push_back(params.toggles);
+
+  // Label
+  for (const auto& c : params.label) {
+    box.push_back(c);
+  }
+  box.push_back(params.label_terminator);  // End of label
+
+  return std::string(box.begin(), box.end());
+}
+
+std::string CreateJumbfContentBox(absl::string_view payload) {
+  std::vector<uint8_t> box;
+
+  // 4 bytes for Lbox
+  // 4 bytes for Tbox
+  // ---
+  // 8 + payload.length()
+  uint32_t size = 8 + payload.size();
+  box.reserve(size);
+
+  // Lbox
+  WriteUint32NetworkOrder(size, &box);
+
+  // Tbox??
+  box.push_back('a');
+  box.push_back('a');
+  box.push_back('a');
+  box.push_back('a');
+
+  // Payload
+  for (const auto& c : payload) {
+    box.push_back(c);
+  }
+
+  return std::string(box.begin(), box.end());
+}
+}  // namespace
+
+std::string CreateJumbf(const JumbfCreatorParams& params) {
+  std::string description_box = CreateJumbfDescriptionBox(params);
+  std::string content_box = CreateJumbfContentBox(params.payload);
+
+  std::vector<uint8_t> box;
+
+  // 4 bytes for Lbox
+  // 4 bytes for Tbox
+  // Maybe 8 bytes for XLBox (if forced)
+  // Description Box
+  // Content Box
+  // ---
+  // 26 + label.length() + 8?
+  uint32_t size = 8 + (params.force_xlbox ? 8 : 0) + description_box.size() +
+                  content_box.size();
+  box.reserve(size);
+
+  uint32_t lbox = size;
+  std::optional<uint64_t> xlbox = std::nullopt;
+  if (params.force_xlbox) {
+    lbox = 1;
+    xlbox = static_cast<uint64_t>(size);
+  }
+
+  // Lbox
+  WriteUint32NetworkOrder(lbox, &box);
+
+  // Tbox
+  WriteUint32NetworkOrder(params.super_box_identifier, &box);
+
+  // Xlbox
+  if (xlbox.has_value()) {
+    WriteUint64NetworkOrder(*xlbox, &box);
+  }
+
+  // Description Box
+  for (const auto& c : description_box) {
+    box.push_back(c);
+  }
+
+  // Content Box
+  for (const auto& c : content_box) {
+    box.push_back(c);
+  }
+
+  return std::string(box.begin(), box.end());
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/testing/jumbf_creator.h b/formats/jpeg/testing/jumbf_creator.h
new file mode 100644
index 0000000..9a2c546
--- /dev/null
+++ b/formats/jpeg/testing/jumbf_creator.h
@@ -0,0 +1,41 @@
+// 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_FORMATS_JPEG_TESTING_JUMBF_CREATOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JUMBF_CREATOR_H_
+
+#include <cstdint>
+#include <string>
+
+namespace credentio {
+
+struct JumbfCreatorParams {
+  uint32_t super_box_identifier = 'jumb';
+  uint32_t description_box_identifier = 'jumd';
+  uint64_t uuid_high = 0x6332706100110010;
+  uint64_t uuid_low = 0x800000AA00389B71;
+  std::string label = "c2pa";
+  std::string payload;
+  uint8_t toggles = 0b00000011;
+  uint8_t label_terminator = 0x00;
+  bool force_xlbox = false;
+};
+
+// Creates a Jpeg JUMBF box, see ISO/IEC 19566-5:2023
+std::string CreateJumbf(const JumbfCreatorParams& params);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_TESTING_JUMBF_CREATOR_H_
diff --git a/formats/pdf/BUILD b/formats/pdf/BUILD
new file mode 100644
index 0000000..df588d5
--- /dev/null
+++ b/formats/pdf/BUILD
@@ -0,0 +1,184 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "objects",
+    hdrs = ["objects.h"],
+    deps = [
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+    ],
+)
+
+cc_library(
+    name = "object_reader",
+    srcs = ["object_reader.cc"],
+    hdrs = ["object_reader.h"],
+    deps = [
+        ":objects",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@re2",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "object_reader_test",
+    srcs = ["object_reader_test.cc"],
+    deps = [
+        ":object_reader",
+        ":objects",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "reader",
+    srcs = ["reader.cc"],
+    hdrs = ["reader.h"],
+    deps = [
+        ":object_reader",
+        ":objects",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:btree",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/memory",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@re2",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "reader_test",
+    srcs = ["reader_test.cc"],
+    deps = [
+        ":objects",
+        ":reader",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":constants",
+        ":objects",
+        ":reader",
+        "//constants:labels",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "@abseil-cpp//absl/algorithm:container",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    deps = [
+        ":extractor",
+        "//formats:byte_range",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        ":constants",
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        ":constants",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/pdf/assessor.cc b/formats/pdf/assessor.cc
new file mode 100644
index 0000000..a065205
--- /dev/null
+++ b/formats/pdf/assessor.cc
@@ -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.
+//
+
+#include "formats/pdf/assessor.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/pdf/constants.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+absl::StatusOr<bool> PdfAssessor::IsSupported(riegeli::Reader& input) const {
+  int64_t starting_position = input.pos();
+
+  std::string file_header;
+  if (!input.Read(kPdfFileHeader.size(), file_header)) {
+    input.Seek(starting_position);
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; header"));
+  }
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; seek"));
+  }
+
+  return file_header == kPdfFileHeader;
+}
+
+}  // namespace credentio
diff --git a/formats/pdf/assessor.h b/formats/pdf/assessor.h
new file mode 100644
index 0000000..a0d806b
--- /dev/null
+++ b/formats/pdf/assessor.h
@@ -0,0 +1,34 @@
+// 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_FORMATS_PDF_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Assessor for the PDF document format.
+class PdfAssessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_ASSESSOR_H_
diff --git a/formats/pdf/assessor_test.cc b/formats/pdf/assessor_test.cc
new file mode 100644
index 0000000..8f659ec
--- /dev/null
+++ b/formats/pdf/assessor_test.cc
@@ -0,0 +1,76 @@
+// 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 "formats/pdf/assessor.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "formats/pdf/constants.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(IsSupportedTest, ErrorTooFewBytes) {
+  std::string document = "\xab";
+  riegeli::StringReader<> input(document);
+
+  EXPECT_THAT(
+      PdfAssessor().IsSupported(input),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof")));
+  EXPECT_EQ(input.pos(), 0);
+}
+
+TEST(IsSupportedTest, FalseForIncorrectHeader) {
+  std::string document = "not_a_pdf_file";
+  riegeli::StringReader<> input(document);
+
+  EXPECT_THAT(PdfAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, ValidStartingBytes) {
+  std::string document =
+      absl::StrCat(kPdfFileHeader, "1.5", "this_is_a_pdf_file");
+  riegeli::StringReader<> input(document);
+
+  EXPECT_THAT(PdfAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidStartingBytesAtOffset2) {
+  std::string document =
+      absl::StrCat("\xab\xcd", kPdfFileHeader, "1.5", "this_is_a_pdf_file");
+  riegeli::StringReader<> input(document);
+
+  // Invalid at 0
+  EXPECT_THAT(PdfAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(PdfAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/pdf/constants.h b/formats/pdf/constants.h
new file mode 100644
index 0000000..537a55a
--- /dev/null
+++ b/formats/pdf/constants.h
@@ -0,0 +1,54 @@
+// 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_FORMATS_PDF_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_CONSTANTS_H_
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// PDF format, see ISO 32000-2 Section 7.5.2 File header.
+constexpr absl::string_view kPdfFileHeader = "%PDF-";
+
+// File trailer dictionary constants.
+constexpr absl::string_view kPdfRootKey = "Root";
+constexpr absl::string_view kPdfSizeKey = "Size";
+
+// Document catalog dictionary constants.
+constexpr absl::string_view kPdfAfKey = "AF";
+constexpr absl::string_view kPdfNamesKey = "Names";
+constexpr absl::string_view kPdfEmbeddedFilesKey = "EmbeddedFiles";
+
+// File specification dictionary constants.
+constexpr absl::string_view kPdfTypeKey = "Type";
+constexpr absl::string_view kPdfFileSpecType = "Filespec";
+constexpr absl::string_view kPdfSubtypeKey = "Subtype";
+constexpr absl::string_view kPdfAfRelationshipKey = "AFRelationship";
+constexpr absl::string_view kPdfFKey = "F";
+constexpr absl::string_view kPdfUfKey = "UF";
+constexpr absl::string_view kPdfEfKey = "EF";
+constexpr absl::string_view kPdfParamsKey = "Params";
+constexpr absl::string_view kPdfModDateKey = "ModDate";
+
+// C2PA Manifest file spec constants. See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_adding_the_manifest_to_a_pdf.
+constexpr absl::string_view kPdfC2paManifestMimeType = "application/c2pa";
+constexpr absl::string_view kPdfC2paManifestAfRelationship = "C2PA_Manifest";
+constexpr absl::string_view kPdfC2paEmbeddedFileName =
+    "content_credential.c2pa";
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_CONSTANTS_H_
diff --git a/formats/pdf/extractor.cc b/formats/pdf/extractor.cc
new file mode 100644
index 0000000..b2e0a9d
--- /dev/null
+++ b/formats/pdf/extractor.cc
@@ -0,0 +1,290 @@
+// 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 "formats/pdf/extractor.h"
+
+#include <optional>
+#include <string>
+#include <utility>
+#include <variant>
+
+#include "absl/algorithm/container.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/byte_range.h"
+#include "formats/pdf/constants.h"
+#include "formats/pdf/objects.h"
+#include "formats/pdf/reader.h"
+#include "jumbf/utils.h"
+#if defined(__GNUC__) || defined(__clang__)
+#include <cxxabi.h>  // IWYU pragma: keep
+
+#include <cstdlib>
+#include <memory>
+#endif
+#include "absl/status/status_macros.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+std::string Demangle(const char* mangled) {
+#if defined(__GNUC__) || defined(__clang__)
+  int status = 0;
+  std::unique_ptr<char, void (*)(void*)> res{
+      abi::__cxa_demangle(mangled, nullptr, nullptr, &status), std::free};
+  return (status == 0) ? res.get() : mangled;
+#else
+  return mangled;
+#endif
+}
+
+struct C2paManifestStore {
+  ByteRange location;
+  std::string content;
+};
+
+// Checks if `file_spec_dict` is a file spec dictionary for an embedded C2PA
+// file stream.
+absl::StatusOr<bool> IsC2paManifestFileSpec(
+    const Object::Dictionary& file_spec_dict) {
+  const auto af_relationship = FindObjectInDictionary<Object::Name>(
+      file_spec_dict, kPdfAfRelationshipKey);
+  if (!af_relationship.ok()) {
+    if (absl::IsNotFound(af_relationship.status())) {
+      return false;
+    }
+    return af_relationship.status();
+  }
+  return af_relationship->value == kPdfC2paManifestAfRelationship;
+}
+
+// Checks that the embedded file stream subtype is as expected.
+absl::StatusOr<bool> IsC2paManifestSubtype(
+    const IndirectObject& embedded_file_obj) {
+  if (!embedded_file_obj.stream_dictionary.has_value()) {
+    return false;
+  }
+  const auto subtype = FindObjectInDictionary<Object::Name>(
+      *embedded_file_obj.stream_dictionary, kPdfSubtypeKey);
+  if (!subtype.ok()) {
+    // Optionally check the subtype if it exists as it is only required in C2PA
+    // v2.2 onward.
+    if (absl::IsNotFound(subtype.status())) {
+      return true;
+    }
+    return subtype.status();
+  }
+  return subtype->value == kPdfC2paManifestMimeType;
+}
+
+// Gets the indirect reference to the embedded file stream from the file spec
+// dictionary under the '/EF/F' key.
+absl::StatusOr<Object::IndirectReference> GetEmbeddedFileRef(
+    const Object::Dictionary& file_spec_dict) {
+  ABSL_ASSIGN_OR_RETURN(
+      const auto ef_dict,
+      FindObjectInDictionary<Object::Dictionary>(file_spec_dict, kPdfEfKey));
+
+  return FindObjectInDictionary<Object::IndirectReference>(ef_dict, kPdfFKey);
+}
+
+// Gets the array of embedded file name and indirect reference pairs from the
+// catalog dictionary under the '/Names/EmbeddedFiles/Names' key.
+absl::StatusOr<Object::Array> GetEmbeddedFilesNameTreeArray(
+    const Object::Dictionary& catalog_dict, PdfReader& reader) {
+  if (auto it = catalog_dict.entries.find(kPdfNamesKey);
+      it != catalog_dict.entries.end()) {
+    // /Catalog/Names
+    Object::Dictionary names_dict;
+    if (std::holds_alternative<Object::IndirectReference>(it->second.value)) {
+      const auto& names_ref =
+          std::get<Object::IndirectReference>(it->second.value);
+      ABSL_ASSIGN_OR_RETURN(const auto names_obj,
+                            reader.GetObject(names_ref.object_number,
+                                             names_ref.generation_number));
+      ABSL_ASSIGN_OR_RETURN(names_dict,
+                            Object::As<Object::Dictionary>(names_obj.object));
+    } else if (std::holds_alternative<Object::Dictionary>(it->second.value)) {
+      names_dict = std::get<Object::Dictionary>(it->second.value);
+    } else {
+      return absl::InvalidArgumentError(
+          absl::StrCat("Unexpected object type found. Expected "
+                       "IndirectReference or Dictionary, but got ",
+                       Demangle(typeid(it->second.value).name())));
+    }
+    // /Catalog/Names/EmbeddedFiles
+    auto name_tree_it = names_dict.entries.find(kPdfEmbeddedFilesKey);
+    if (name_tree_it == names_dict.entries.end()) {
+      return absl::NotFoundError("No '/EmbeddedFiles' dictionary found.");
+    }
+    Object::Dictionary name_tree_dict;
+    if (std::holds_alternative<Object::IndirectReference>(
+            name_tree_it->second.value)) {
+      const auto& name_tree_ref =
+          std::get<Object::IndirectReference>(name_tree_it->second.value);
+      ABSL_ASSIGN_OR_RETURN(const auto name_tree_obj,
+                            reader.GetObject(name_tree_ref.object_number,
+                                             name_tree_ref.generation_number));
+      ABSL_ASSIGN_OR_RETURN(
+          name_tree_dict, Object::As<Object::Dictionary>(name_tree_obj.object));
+    } else if (std::holds_alternative<Object::Dictionary>(
+                   name_tree_it->second.value)) {
+      name_tree_dict = std::get<Object::Dictionary>(name_tree_it->second.value);
+    } else {
+      return absl::InvalidArgumentError(
+          absl::StrCat("Unexpected object type found. Expected "
+                       "IndirectReference or Dictionary, but got ",
+                       Demangle(typeid(name_tree_it->second.value).name())));
+    }
+    // /Catalog/Names/EmbeddedFiles/Names
+    return FindObjectInDictionary<Object::Array>(name_tree_dict, kPdfNamesKey);
+  } else {
+    return absl::NotFoundError(
+        "No '/Names' dictionary found in the document catalog.");
+  }
+}
+
+absl::StatusOr<C2paManifestStore> ExtractManifestStoreInternal(
+    riegeli::Reader& input) {
+  ABSL_ASSIGN_OR_RETURN(auto reader, PdfReader::Create(&input));
+
+  // Locate the active manifest store in the file trailer dictionary.
+  const Object::Dictionary& file_trailer_dict =
+      reader->file_trailer_dictionary();
+
+  ABSL_ASSIGN_OR_RETURN(const auto root_ref,
+                        FindObjectInDictionary<Object::IndirectReference>(
+                            file_trailer_dict, kPdfRootKey));
+
+  ABSL_ASSIGN_OR_RETURN(
+      auto root_obj,
+      reader->GetObject(root_ref.object_number, root_ref.generation_number));
+  ABSL_ASSIGN_OR_RETURN(auto catalog_dict,
+                        Object::As<Object::Dictionary>(root_obj.object));
+
+  const auto& file_spec_array =
+      FindObjectInDictionary<Object::Array>(catalog_dict, kPdfAfKey);
+  if (absl::IsNotFound(file_spec_array.status())) {
+    // Early return if the document catalog dictionary does not contain the
+    // required '/AF' entry for C2PA manifest extraction.
+    return absl::NotFoundError("No manifest store found.");
+  }
+  ABSL_RETURN_IF_ERROR(file_spec_array.status());
+
+  ABSL_ASSIGN_OR_RETURN(auto name_tree_array,
+                        GetEmbeddedFilesNameTreeArray(catalog_dict, *reader));
+
+  for (const auto& file_spec_obj : file_spec_array->objects) {
+    Object::Dictionary file_spec_dict;
+    Object::IndirectReference file_spec_ref;
+    if (std::holds_alternative<Object::IndirectReference>(
+            file_spec_obj.value)) {
+      // C2PA manifest file spec is expected to be referenced indirectly from
+      // the '/AF' array.
+      file_spec_ref = std::get<Object::IndirectReference>(file_spec_obj.value);
+      ABSL_ASSIGN_OR_RETURN(auto file_spec_dict_obj,
+                            reader->GetObject(file_spec_ref.object_number,
+                                              file_spec_ref.generation_number));
+      ABSL_ASSIGN_OR_RETURN(file_spec_dict, Object::As<Object::Dictionary>(
+                                                file_spec_dict_obj.object));
+    } else {
+      // Not an indirect reference, continue searching.
+      continue;
+    }
+
+    ABSL_ASSIGN_OR_RETURN(auto is_c2pa_manifest_file_spec,
+                          IsC2paManifestFileSpec(file_spec_dict));
+    if (!is_c2pa_manifest_file_spec) {
+      continue;
+    }
+    ABSL_ASSIGN_OR_RETURN(auto embedded_file_ref,
+                          GetEmbeddedFileRef(file_spec_dict));
+
+    // The C2PA manifest file spec object should also be referenced from the
+    // '/EmbeddedFiles' NameTree.
+    if (absl::c_any_of(name_tree_array.objects, [&](const auto& name_tree_obj) {
+          return std::holds_alternative<Object::IndirectReference>(
+                     name_tree_obj.value) &&
+                 std::get<Object::IndirectReference>(name_tree_obj.value)
+                         .object_number == file_spec_ref.object_number &&
+                 std::get<Object::IndirectReference>(name_tree_obj.value)
+                         .generation_number == file_spec_ref.generation_number;
+        })) {
+      // The embedded file reference was found in the NameTree.
+      // Proceed with extraction.
+    } else {
+      // The embedded file reference was NOT found in the NameTree.
+      continue;
+    }
+
+    ABSL_ASSIGN_OR_RETURN(
+        auto embedded_file_obj,
+        reader->GetObject(embedded_file_ref.object_number,
+                          embedded_file_ref.generation_number));
+    ABSL_ASSIGN_OR_RETURN(auto is_c2pa_manifest_subtype,
+                          IsC2paManifestSubtype(embedded_file_obj));
+    if (!is_c2pa_manifest_subtype) {
+      continue;
+    }
+    ABSL_ASSIGN_OR_RETURN(auto embedded_file_stream,
+                          Object::As<Object::Stream>(embedded_file_obj.object));
+    ABSL_ASSIGN_OR_RETURN(
+        auto embedded_file_offset,
+        reader->GetObjectOffset(embedded_file_ref.object_number,
+                                embedded_file_ref.generation_number));
+    return C2paManifestStore{
+        .location = {.offset = embedded_file_offset,
+                     .length = input.pos() - embedded_file_offset},
+        .content = embedded_file_stream.raw_value};
+  }
+  return absl::NotFoundError("No manifest store found.");
+}
+}  // namespace
+
+absl::StatusOr<std::string> PdfExtractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  ABSL_ASSIGN_OR_RETURN(auto c2pa_manifest_store,
+                        ExtractManifestStoreInternal(input));
+  return c2pa_manifest_store.content;
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+PdfExtractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                           ExtractOptions options) const {
+  auto c2pa_manifest_store = ExtractManifestStoreInternal(input);
+
+  // Extraction failed but C2PA is required, return an error.
+  if (!c2pa_manifest_store.ok() && options.requires_c2pa) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  // Extraction failed but C2PA is not required, return nullopt.
+  if (!c2pa_manifest_store.ok() && !options.requires_c2pa) {
+    return std::nullopt;
+  }
+  // Extraction succeeded, return the location.
+  return c2pa_manifest_store->location;
+}
+
+bool PdfExtractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+}  // namespace credentio
diff --git a/formats/pdf/extractor.h b/formats/pdf/extractor.h
new file mode 100644
index 0000000..839d28a
--- /dev/null
+++ b/formats/pdf/extractor.h
@@ -0,0 +1,54 @@
+// 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_FORMATS_PDF_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_byte_info.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Implements the C2PA Manifest Store extractor for PDF files as described in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_pdfs
+class PdfExtractor : public FormatExtractor {
+ public:
+  PdfExtractor() = default;
+  ~PdfExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError("PDF does not have a concept of boxes");
+  }
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_EXTRACTOR_H_
diff --git a/formats/pdf/extractor_test.cc b/formats/pdf/extractor_test.cc
new file mode 100644
index 0000000..60ded2f
--- /dev/null
+++ b/formats/pdf/extractor_test.cc
@@ -0,0 +1,670 @@
+// 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 "formats/pdf/extractor.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "formats/byte_range.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+using ::testing::Optional;
+
+#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
+
+constexpr absl::string_view kTestPdfContent =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</EmbeddedFiles 4 0 R>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentNoC2paSubtype =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</EmbeddedFiles 4 0 R>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000155 00000 n \n"
+    "0000000238 00000 n \n"
+    "0000000298 00000 n \n"
+    "0000000338 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "397\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentNoEmbeddedFilesRef =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(not_a_c2pa_manifest.txt) 1 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</EmbeddedFiles 4 0 R>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentNamesNotDict =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names [1 0 R]>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 5\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "trailer\n"
+    "<</Root 4 0 R>>\n"
+    "startxref\n"
+    "328\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentEmbeddedFilesDirect =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names <</EmbeddedFiles <</Names "
+    "[(content_credential.c2pa) 3 0 R]>> >> >>\n"
+    "endobj\n"
+    "xref\n"
+    "0 5\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "trailer\n"
+    "<</Root 4 0 R>>\n"
+    "startxref\n"
+    "386\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentNoAfRelationship =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>>                                /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</EmbeddedFiles 4 0 R>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentDifferentAfRelationship =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /Other         /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</EmbeddedFiles 4 0 R>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentNonIndirectAf =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</EmbeddedFiles 4 0 R>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [123  ] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentInvalidEmbeddedFilesType =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</EmbeddedFiles 12345>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentNamesDictNotDict =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "12345                   \n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentNoEmbeddedFiles =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</OtherFiles___ 4 0 R>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 5 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+constexpr absl::string_view kTestPdfContentNamesObjectNotFound =
+    "%PDF-1.7\n"
+    "%%%%%\n"
+    "1 0 obj\n"
+    "<</Length 11>>\n"
+    "stream\n"
+    "test_stream\n"
+    "endstream\n"
+    "endobj\n"
+    "2 0 obj\n"
+    "<</Length 33 /Subtype /application#2fc2pa>>\n"
+    "stream\n"
+    "test_c2pa_manifest_raw_bytestream\n"
+    "endstream\n"
+    "endobj\n"
+    "3 0 obj\n"
+    "<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest /Type /FileSpec>>\n"
+    "endobj\n"
+    "4 0 obj\n"
+    "<</Names [(content_credential.c2pa) 3 0 R]>>\n"
+    "endobj\n"
+    "5 0 obj\n"
+    "<</EmbeddedFiles 4 0 R>>\n"
+    "endobj\n"
+    "6 0 obj\n"
+    "<</AF [3 0 R] /Type /Catalog /Names 9 0 R>>\n"
+    "endobj\n"
+    "xref\n"
+    "0 7\n"
+    "0000000000 65535 f \n"
+    "0000000015 00000 n \n"
+    "0000000074 00000 n \n"
+    "0000000184 00000 n \n"
+    "0000000267 00000 n \n"
+    "0000000327 00000 n \n"
+    "0000000367 00000 n \n"
+    "trailer\n"
+    "<</Root 6 0 R>>\n"
+    "startxref\n"
+    "426\n"
+    "%%EOF";
+
+TEST(PdfExtractorTest, ExtractManifestStoreV22EmbeddedFilesDirect) {
+  riegeli::StringReader<> input(kTestPdfContentEmbeddedFilesDirect);
+  PdfExtractor extractor;
+  ASSERT_OK_AND_ASSIGN(auto manifest_store,
+                       extractor.ExtractManifestStore(input));
+  EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream");
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreV22) {
+  riegeli::StringReader<> input(kTestPdfContent);
+  PdfExtractor extractor;
+  ASSERT_OK_AND_ASSIGN(auto manifest_store,
+                       extractor.ExtractManifestStore(input));
+  EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream");
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreNoSubtype) {
+  riegeli::StringReader<> input(kTestPdfContentNoC2paSubtype);
+  PdfExtractor extractor;
+  ASSERT_OK_AND_ASSIGN(auto manifest_store,
+                       extractor.ExtractManifestStore(input));
+  EXPECT_THAT(manifest_store, "test_c2pa_manifest_raw_bytestream");
+}
+
+TEST(PdfExtractorTest,
+     ExtractManifestStoreThrowNotFoundErrorIfNoEmbeddedFilesRef) {
+  riegeli::StringReader<> input(kTestPdfContentNoEmbeddedFilesRef);
+  PdfExtractor extractor;
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreLocation) {
+  riegeli::StringReader<> input(kTestPdfContent);
+  PdfExtractor extractor;
+  ASSERT_OK_AND_ASSIGN(
+      auto manifest_store_location,
+      extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}));
+  EXPECT_THAT(manifest_store_location,
+              Optional(ByteRange{.offset = 74, .length = 109}));
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreLocationNoSubtype) {
+  riegeli::StringReader<> input(kTestPdfContentNoC2paSubtype);
+  PdfExtractor extractor;
+  ASSERT_OK_AND_ASSIGN(
+      auto manifest_store_location,
+      extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}));
+  EXPECT_THAT(manifest_store_location,
+              Optional(ByteRange{.offset = 74, .length = 80}));
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreFailsWithInvalidNamesType) {
+  riegeli::StringReader<> input(kTestPdfContentNamesNotDict);
+  PdfExtractor extractor;
+  EXPECT_THAT(
+      extractor.ExtractManifestStore(input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               ::testing::AllOf(HasSubstr("Unexpected object type found."),
+                                HasSubstr("credentio::Object::"))));
+}
+
+TEST(PdfExtractorTest, ExtractBoxesUnimplemented) {
+  riegeli::StringReader<> input("test_content");
+  PdfExtractor extractor;
+  EXPECT_THAT(extractor.ExtractBoxes(input, {.requires_c2pa = true}),
+              StatusIs(absl::StatusCode::kUnimplemented));
+}
+
+TEST(PdfExtractorTest,
+     ExtractManifestStoreThrowsNotFoundErrorIfNoAfRelationship) {
+  riegeli::StringReader<> input(kTestPdfContentNoAfRelationship);
+  PdfExtractor extractor;
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No manifest store found.")));
+}
+
+TEST(PdfExtractorTest,
+     ExtractManifestStoreThrowsNotFoundErrorIfDifferentAfRelationship) {
+  riegeli::StringReader<> input(kTestPdfContentDifferentAfRelationship);
+  PdfExtractor extractor;
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No manifest store found.")));
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreThrowsNotFoundErrorIfNonIndirectAf) {
+  riegeli::StringReader<> input(kTestPdfContentNonIndirectAf);
+  PdfExtractor extractor;
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No manifest store found.")));
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreFailsWithInvalidEmbeddedFilesType) {
+  riegeli::StringReader<> input(kTestPdfContentInvalidEmbeddedFilesType);
+  PdfExtractor extractor;
+  EXPECT_THAT(
+      extractor.ExtractManifestStore(input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               ::testing::AllOf(HasSubstr("Unexpected object type found."),
+                                HasSubstr("credentio::Object::"))));
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreFailsIfNamesObjectNotDict) {
+  riegeli::StringReader<> input(kTestPdfContentNamesDictNotDict);
+  PdfExtractor extractor;
+  EXPECT_THAT(
+      extractor.ExtractManifestStore(input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Object is not a credentio::Object::Dictionary")));
+}
+
+TEST(PdfExtractorTest,
+     ExtractManifestStoreThrowsNotFoundErrorIfNoEmbeddedFiles) {
+  riegeli::StringReader<> input(kTestPdfContentNoEmbeddedFiles);
+  PdfExtractor extractor;
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No '/EmbeddedFiles' dictionary found.")));
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreFailsIfNamesObjectNotFound) {
+  riegeli::StringReader<> input(kTestPdfContentNamesObjectNotFound);
+  PdfExtractor extractor;
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No offset found for object number: 9")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/pdf/format.cc b/formats/pdf/format.cc
new file mode 100644
index 0000000..b69a3f4
--- /dev/null
+++ b/formats/pdf/format.cc
@@ -0,0 +1,35 @@
+// 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 "formats/pdf/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+#include "formats/pdf/assessor.h"
+#include "formats/pdf/extractor.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreatePdfFormat() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<PdfAssessor>(),
+      .extractor = std::make_unique<PdfExtractor>(),
+      .mime_types = {"application/pdf"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/pdf/format.h b/formats/pdf/format.h
new file mode 100644
index 0000000..5f39106
--- /dev/null
+++ b/formats/pdf/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_PDF_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreatePdfFormat();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_FORMAT_H_
diff --git a/formats/pdf/object_reader.cc b/formats/pdf/object_reader.cc
new file mode 100644
index 0000000..a92ec79
--- /dev/null
+++ b/formats/pdf/object_reader.cc
@@ -0,0 +1,673 @@
+// 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 "formats/pdf/object_reader.h"
+
+#include <cstdint>
+#include <string>
+#include <utility>
+#include <variant>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/ascii.h"
+#include "absl/strings/match.h"
+#include "absl/strings/numbers.h"
+#include "absl/strings/str_cat.h"
+#include "formats/pdf/objects.h"
+#include "re2/re2.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;        // 10 MiB
+constexpr uint64_t kMaxStringTokenLength = 1024 * 1024 * 10;  // 10 MiB
+constexpr uint64_t kMaxNameTokenLength = 4096;
+constexpr uint64_t kMaxNumberTokenLength = 128;
+constexpr int kMaxObjectRecursionDepth = 100;
+
+// PDF White-space characters. See ISO 32000-2 Section 7.2.3 Table 1.
+constexpr bool IsPdfWhiteSpace(uint8_t c) {
+  return c == 0x00 || c == 0x09 || c == 0x0a || c == 0x0c || c == 0x0d ||
+         c == 0x20;
+}
+// PDF Delimiter characters. See ISO 32000-2 Section 7.2.3 Table 2.
+constexpr bool IsPdfDelimiter(uint8_t c) {
+  return c == 0x28 || c == 0x29 || c == 0x3c || c == 0x3e || c == 0x5b ||
+         c == 0x5d || c == 0x7b || c == 0x7d || c == 0x2f || c == 0x25;
+}
+
+static constexpr LazyRE2 kIndirectReferencePattern = {
+    R"(^[+-]?(\d+)\s+(\d+)\s+R[^a-zA-Z])"};
+
+// Reads either an integer or a real number from the given input stream at the
+// current position.
+absl::Status ReadNumber(riegeli::Reader& input, Object& obj) {
+  uint8_t token = 0x00;
+  std::string buffer;
+
+  while (true) {
+    if (buffer.size() >= kMaxNumberTokenLength) {
+      return absl::InvalidArgumentError(
+          absl::StrCat("Number token exceeds maximum allowed length: ",
+                       kMaxNumberTokenLength));
+    }
+    if (!input.ReadByte(token)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read beginning delimiter"));
+    }
+    // A number can be terminated by any white-space character or delimiter.
+    if (IsPdfWhiteSpace(token) || IsPdfDelimiter(token)) {
+      if (!input.Seek(input.pos() - 1)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to seek to offset"));
+      }
+      break;
+    }
+    buffer += token;
+  }
+  if (absl::StrContains(buffer, '.')) {
+    obj.value = Object::RealNumber{.raw_value = std::move(buffer)};
+  } else {
+    int64_t value;
+    if (!absl::SimpleAtoi(buffer, &value)) {
+      return absl::InvalidArgumentError(
+          absl::StrCat("Failed to parse integer. Invalid value: ", buffer));
+    }
+    obj.value = Object::Integer{.value = std::move(value)};
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ReadBoolean(riegeli::Reader& input, Object& obj) {
+  std::string buffer;
+  if (!input.Read(5, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  if (buffer.substr(0, 4) == "true") {
+    obj.value = Object::Boolean{.value = true};
+    if (!input.Seek(input.pos() - 1)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek to offset"));
+    }
+  } else if (buffer == "false") {
+    obj.value = Object::Boolean{.value = false};
+  } else {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Failed to parse boolean. Invalid value: ", buffer));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ReadLiteralString(riegeli::Reader& input, Object& obj) {
+  uint8_t token = 0x00;
+  std::string buffer;
+  int32_t parentheses_count = 1;
+
+  // Consume the beginning delimiter.
+  if (!input.ReadByte(token)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read beginning delimiter"));
+  }
+  if (token != '(') {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Invalid delimiter. Expected '(' but got 0x",
+                     absl::Hex(token), " at offset ", input.pos() - 1));
+  }
+
+  while (true) {
+    if (buffer.size() >= kMaxStringTokenLength) {
+      return absl::InvalidArgumentError(
+          absl::StrCat("Literal string exceeds maximum allowed length: ",
+                       kMaxStringTokenLength));
+    }
+    if (!input.ReadByte(token)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read beginning delimiter"));
+    }
+    if (token == '(') {
+      parentheses_count++;
+    }
+    if (token == ')' && --parentheses_count == 0) {
+      break;
+    }
+    buffer += token;
+  }
+  obj.value = Object::LiteralString{.raw_value = std::move(buffer)};
+  return absl::OkStatus();
+}
+
+absl::Status ReadHexadecimalString(riegeli::Reader& input, Object& obj) {
+  uint8_t token = 0x00;
+  std::string buffer;
+
+  // Consume the beginning delimiter.
+  if (!input.ReadByte(token)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read beginning delimiter"));
+  }
+  if (token != '<') {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Invalid delimiter. Expected '<' but got 0x",
+                     absl::Hex(token), " at offset ", input.pos() - 1));
+  }
+
+  while (true) {
+    if (buffer.size() >= kMaxStringTokenLength) {
+      return absl::InvalidArgumentError(
+          absl::StrCat("Hexadecimal string exceeds maximum allowed length: ",
+                       kMaxStringTokenLength));
+    }
+    if (!input.ReadByte(token)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read beginning delimiter"));
+    }
+    if (token == '>') {
+      break;
+    }
+    buffer += token;
+  }
+  obj.value = Object::HexadecimalString{.raw_value = std::move(buffer)};
+  return absl::OkStatus();
+}
+
+absl::Status ReadName(riegeli::Reader& input, Object& obj) {
+  uint8_t token = 0x00;
+  std::string buffer;
+
+  // Consume the beginning delimiter.
+  if (!input.ReadByte(token)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read beginning delimiter"));
+  }
+  if (token != '/') {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Invalid delimiter. Expected '/' but got 0x",
+                     absl::Hex(token), " at offset ", input.pos() - 1));
+  }
+
+  while (true) {
+    if (buffer.size() >= kMaxNameTokenLength) {
+      return absl::InvalidArgumentError(absl::StrCat(
+          "Name token exceeds maximum allowed length: ", kMaxNameTokenLength));
+    }
+    if (!input.ReadByte(token)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read beginning delimiter"));
+    }
+    // A name can be terminated by any white-space character or delimiter.
+    if (IsPdfWhiteSpace(token) || IsPdfDelimiter(token)) {
+      if (!input.Seek(input.pos() - 1)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to seek to offset"));
+      }
+      break;
+    }
+    if (token == '#') {
+      std::string hex_str(2, '\0');
+      if (!input.Read(2, hex_str)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to read hex string"));
+      }
+      if (!absl::ascii_isxdigit(hex_str[0]) ||
+          !absl::ascii_isxdigit(hex_str[1])) {
+        return absl::InvalidArgumentError("Invalid hex string");
+      }
+      int hex_val = 0;
+      if (!absl::SimpleHexAtoi(hex_str, &hex_val)) {
+        return absl::InvalidArgumentError("Invalid hex string");
+      }
+      token = hex_val;
+    }
+    buffer += token;
+  }
+  obj.value = Object::Name{.value = std::move(buffer)};
+  return absl::OkStatus();
+}
+
+absl::Status ReadArray(riegeli::Reader& input, Object& obj, int depth) {
+  uint8_t token = 0x00;
+  Object::Array array;
+
+  // Consume the beginning delimiter.
+  if (!input.ReadByte(token)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read beginning delimiter"));
+  }
+  if (token != '[') {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Invalid delimiter. Expected '[' but got '",
+                     absl::Hex(token), "' at offset ", input.pos() - 1));
+  }
+
+  while (true) {
+    ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+    if (!input.ReadByte(token)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read beginning delimiter"));
+    }
+    if (token == ']') {
+      break;
+    }
+    Object sub_obj;
+    ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos() - 1, sub_obj, depth));
+    array.objects.push_back(std::move(sub_obj));
+  }
+  obj.value = std::move(array);
+
+  return absl::OkStatus();
+}
+
+absl::Status ReadNull(riegeli::Reader& input, Object& obj) {
+  std::string buffer;
+  if (!input.Read(4, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  if (buffer == "null") {
+    obj.value = Object::Null{};
+  } else {
+    return absl::InvalidArgumentError(absl::StrCat("Invalid value: ", buffer));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ReadDictionary(riegeli::Reader& input, Object& obj, int depth) {
+  std::string buffer;
+  Object::Dictionary dictionary;
+
+  if (!input.Read(2, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  if (buffer != "<<") {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "Invalid delimiter. Expected '<<' but got '", buffer, "'"));
+  }
+
+  while (true) {
+    ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+    if (!input.Read(2, buffer)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read buffer"));
+    }
+    if (buffer == ">>") {
+      break;
+    }
+    if (!input.Seek(input.pos() - 2)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek to offset"));
+    }
+    Object name_obj;
+    ABSL_RETURN_IF_ERROR(ReadName(input, name_obj));
+    ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+    Object value_obj;
+    ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), value_obj, depth));
+    ABSL_ASSIGN_OR_RETURN(auto name, Object::As<Object::Name>(name_obj));
+    dictionary.entries.insert({name.value, std::move(value_obj)});
+  }
+  obj.value = std::move(dictionary);
+
+  return absl::OkStatus();
+}
+
+absl::Status ReadStream(riegeli::Reader& input, int64_t length, Object& obj) {
+  std::string buffer;
+  Object::Stream stream;
+
+  if (!input.Read(6, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  if (buffer != "stream") {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "Malformed keyword. Expected 'stream' but got '", buffer, "'"));
+  }
+  if (!input.Read(2, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  // The spec requires "stream" keyword to be followed by a CARRIAGE RETURN and
+  // a LINE FEED, or just a LINE FEED.
+  if (buffer == "\r\n") {  // CARRIAGE RETURN + LINE FEED
+    // Do nothing.
+  } else if (buffer[0] == '\n') {  // LINE FEED
+    // Seek back to the end of the LINE FEED.
+    if (!input.Seek(input.pos() - 1)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek to offset"));
+    }
+  } else {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "Malformed stream. Expected '\r\n' or '\n' but got '", buffer, "'"));
+  }
+  if (length > kMaxPayloadSize) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Stream length exceeds maximum allowed size: ", length,
+                     " > ", kMaxPayloadSize));
+  }
+  if (!input.Read(length, stream.raw_value)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  obj.value = std::move(stream);
+  // Skip over the end-of-line marker.
+  ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+  if (!input.Read(9, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  if (buffer != "endstream") {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "Malformed keyword. Expected 'endstream' but got '", buffer, "'"));
+  }
+  return absl::OkStatus();
+}
+}  // namespace
+
+absl::Status ReadObject(riegeli::Reader& input, int64_t obj_offset, Object& obj,
+                        int depth) {
+  if (depth > kMaxObjectRecursionDepth) {
+    return absl::InvalidArgumentError("Max nesting depth exceeded.");
+  }
+  if (!input.Seek(obj_offset)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+
+  uint8_t token = 0x00;
+
+  ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+  if (!input.ReadByte(token)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read beginning delimiter"));
+  }
+  if (!input.Seek(input.pos() - 1)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+  switch (token) {
+    case '+':
+    case '-':
+    case '.':
+    case '0':
+    case '1':
+    case '2':
+    case '3':
+    case '4':
+    case '5':
+    case '6':
+    case '7':
+    case '8':
+    case '9': {  // Numbers or indirect reference
+      std::string buffer(20, '\0');
+      // Read more bytes to check if this is an indirect reference.
+      if (!input.Read(20, buffer)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to read buffer"));
+      }
+      if (!input.Seek(input.pos() - 20)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to seek to offset"));
+      }
+      if (uint32_t obj_num, gen_num; RE2::PartialMatch(
+              buffer, *kIndirectReferencePattern, &obj_num, &gen_num)) {
+        obj.value = Object::IndirectReference{.object_number = obj_num,
+                                              .generation_number = gen_num};
+        auto pos = buffer.find('R');
+        // This should never happen.
+        if (pos == std::string::npos) {
+          return absl::InternalError(
+              "Failed to locate keyword 'R' in indirect reference buffer.");
+        }
+        if (!input.Seek(input.pos() + pos + 1)) {
+          return input.StatusOrAnnotate(
+              absl::DataLossError("Failed to seek to offset"));
+        }
+        break;
+      }
+      ABSL_RETURN_IF_ERROR(ReadNumber(input, obj));
+      break;
+    }
+    case 't':
+    case 'f': {  // Boolean
+      ABSL_RETURN_IF_ERROR(ReadBoolean(input, obj));
+      break;
+    }
+    case '(': {  // Literal string
+      ABSL_RETURN_IF_ERROR(ReadLiteralString(input, obj));
+      break;
+    }
+    case '<': {  // Hexadecimal string or Dictionary
+      // Read one more byte to determine the object type.
+      std::string buffer(2, '\0');
+      if (!input.Read(2, buffer)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to read buffer"));
+      }
+      if (!input.Seek(input.pos() - 2)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to seek to offset"));
+      }
+      if (buffer == "<<") {  // Dictionary
+        ABSL_RETURN_IF_ERROR(ReadDictionary(input, obj, depth + 1));
+      } else {
+        // Hexadecimal string
+        ABSL_RETURN_IF_ERROR(ReadHexadecimalString(input, obj));
+      }
+      break;
+    }
+    case '/': {  // Name
+      ABSL_RETURN_IF_ERROR(ReadName(input, obj));
+      break;
+    }
+    case '[': {  // Array
+      ABSL_RETURN_IF_ERROR(ReadArray(input, obj, depth + 1));
+      break;
+    }
+    case 'n': {  // Null
+      ABSL_RETURN_IF_ERROR(ReadNull(input, obj));
+      break;
+    }
+    case 's': {  // Stream
+      std::string buffer(6, '\0');
+      if (!input.Read(6, buffer)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to read buffer"));
+      }
+      if (!input.Seek(input.pos() - 6)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to seek to offset"));
+      }
+      if (buffer == "stream") {
+        return absl::InvalidArgumentError(
+            "Stream must reside in an indirect object. Call "
+            "ReadIndirectObject() instead.");
+      } else {
+        return absl::InvalidArgumentError(absl::StrCat(
+            "Malformed keyword. Expected 'stream' but got '", buffer, "'"));
+      }
+      break;
+    }
+    default:
+      return absl::InvalidArgumentError(
+          absl::StrCat("Unsupported delimiter: 0x", absl::Hex(token),
+                       " at offset ", input.pos()));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ReadIndirectObject(
+    riegeli::Reader& input, int64_t obj_offset,
+    absl::AnyInvocable<
+        absl::StatusOr<IndirectObject>(const Object::IndirectReference&)>
+        indirect_reference_resolver,
+    IndirectObject& obj) {
+  if (!input.Seek(obj_offset)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+
+  uint8_t token = 0x00;
+  std::string buffer;
+
+  // Read the object number.
+  while (true) {
+    if (buffer.size() >= kMaxNumberTokenLength) {
+      return absl::InvalidArgumentError(
+          absl::StrCat("Object number token exceeds maximum allowed length: ",
+                       kMaxNumberTokenLength));
+    }
+    if (!input.ReadByte(token)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read beginning delimiter"));
+    }
+    if (IsPdfWhiteSpace(token)) {
+      break;
+    }
+    absl::StrAppend(&buffer, std::string(1, token));
+  }
+  bool success = absl::SimpleAtoi(buffer, &obj.object_number);
+  if (!success) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Failed to parse object number. Invalid value: ", buffer));
+  }
+
+  // Read the generation number.
+  buffer.clear();
+  while (true) {
+    if (buffer.size() >= kMaxNumberTokenLength) {
+      return absl::InvalidArgumentError(absl::StrCat(
+          "Generation number token exceeds maximum allowed length: ",
+          kMaxNumberTokenLength));
+    }
+    if (!input.ReadByte(token)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read beginning delimiter"));
+    }
+    if (IsPdfWhiteSpace(token)) {
+      break;
+    }
+    absl::StrAppend(&buffer, std::string(1, token));
+  }
+  success = absl::SimpleAtoi(buffer, &obj.generation_number);
+  if (!success) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "Failed to parse generation number. Invalid value: ", buffer));
+  }
+
+  // Read the enclosed object.
+  ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+  if (!input.Read(3, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  if (buffer != "obj") {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "Malformed keyword. Expected 'obj' but got '", buffer, "'"));
+  }
+  Object temp_obj;
+  ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), temp_obj));
+  ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+  if (!input.Read(6, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  if (!input.Seek(input.pos() - 6)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+  if (buffer == "stream") {
+    // Maker indicates that this is a stream object, hence the object preceding
+    // it must be the stream dictionary.
+    ABSL_ASSIGN_OR_RETURN(obj.stream_dictionary,
+                          Object::As<Object::Dictionary>(temp_obj));
+
+    ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+    // Determine the length of the stream.
+    int64_t length = -1;
+    if (const auto it = obj.stream_dictionary->entries.find("Length");
+        it != obj.stream_dictionary->entries.end()) {
+      if (std::holds_alternative<Object::IndirectReference>(it->second.value)) {
+        ABSL_ASSIGN_OR_RETURN(
+            auto indirect_ref,
+            Object::As<Object::IndirectReference>(it->second));
+        // Save the current input stream position, as the
+        // `indirect_reference_resolver` might change it.
+        auto pos = input.pos();
+        ABSL_ASSIGN_OR_RETURN(auto indirect_length_obj,
+                              indirect_reference_resolver(indirect_ref));
+
+        ABSL_ASSIGN_OR_RETURN(auto length_obj, Object::As<Object::Integer>(
+                                                   indirect_length_obj.object));
+        length = length_obj.value;
+        // Seek back to the saved position to continue reading the stream.
+        if (!input.Seek(pos)) {
+          return input.StatusOrAnnotate(
+              absl::DataLossError("Failed to seek to offset"));
+        }
+      } else {
+        ABSL_ASSIGN_OR_RETURN(auto length_obj,
+                              Object::As<Object::Integer>(it->second));
+        length = length_obj.value;
+      }
+    } else {
+      return absl::DataLossError(
+          "Stream dictionary does not contain a 'Length' entry.");
+    }
+    ABSL_RETURN_IF_ERROR(ReadStream(input, length, obj.object));
+    // Skip over the end-of-object marker.
+    ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+    if (!input.Read(6, buffer)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read buffer"));
+    }
+  } else {
+    obj.object = std::move(temp_obj);
+  }
+  if (buffer != "endobj") {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "Malformed keyword. Expected 'endobj' but got '", buffer, "'"));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status SkipOverWhiteSpaces(riegeli::Reader& input) {
+  uint8_t token = 0x00;
+  while (true) {
+    if (!input.ReadByte(token)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read beginning delimiter"));
+    }
+    if (token == '%') {
+      // It's a comment, skip until EOL or EOF.
+      while (true) {
+        if (!input.ReadByte(token)) {
+          return input.StatusOrAnnotate(
+              absl::DataLossError("Failed to read beginning delimiter"));
+        }
+        if (token == 0x0a || token == 0x0d) {  // LF or CR
+          break;
+        }
+      }
+      continue;
+    }
+    if (!IsPdfWhiteSpace(token)) {
+      // Seek back to the last non-whitespace character.
+      if (!input.Seek(input.pos() - 1)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to seek to offset"));
+      }
+      break;
+    }
+  }
+  return absl::OkStatus();
+}
+}  // namespace credentio
diff --git a/formats/pdf/object_reader.h b/formats/pdf/object_reader.h
new file mode 100644
index 0000000..f196fb9
--- /dev/null
+++ b/formats/pdf/object_reader.h
@@ -0,0 +1,49 @@
+// 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_FORMATS_PDF_OBJECT_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_READER_H_
+
+#include <cstdint>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/pdf/objects.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Reads a PDF object from the given input stream at the given offset.
+absl::Status ReadObject(riegeli::Reader& input, int64_t obj_offset, Object& obj,
+                        int depth = 0);
+
+// Reads a PDF indirect object from the given input stream at the given offset.
+// `indirect_reference_resolver` is used to resolve any indirect references
+// encountered during the reading process.
+absl::Status ReadIndirectObject(
+    riegeli::Reader& input, int64_t obj_offset,
+    absl::AnyInvocable<
+        absl::StatusOr<IndirectObject>(const Object::IndirectReference&)>
+        indirect_reference_resolver,
+    IndirectObject& obj);
+
+// Reads and skips over white-space characters and comments until the first
+// non-whitespace, non-comment character is encountered.
+absl::Status SkipOverWhiteSpaces(riegeli::Reader& input);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_READER_H_
diff --git a/formats/pdf/object_reader_test.cc b/formats/pdf/object_reader_test.cc
new file mode 100644
index 0000000..97b21b8
--- /dev/null
+++ b/formats/pdf/object_reader_test.cc
@@ -0,0 +1,772 @@
+// 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 "formats/pdf/object_reader.h"
+
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "formats/pdf/objects.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+using ::testing::Pair;
+using ::testing::UnorderedElementsAre;
+using ::testing::VariantWith;
+
+// This is used to pad the end of the test input because an object should never
+// be terminated by EOF per PDF spec.
+constexpr absl::string_view kContentPadding = "                          %%EOF";
+
+absl::AnyInvocable<
+    absl::StatusOr<IndirectObject>(const Object::IndirectReference&)>
+MockResolver(const IndirectObject& obj = IndirectObject()) {
+  return [obj](const Object::IndirectReference& ref) { return obj; };
+}
+
+TEST(ReadObjectTest, ReadValidBoolean) {
+  std::string content = absl::StrCat("true false", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Boolean>(Object::Boolean{.value = true}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Boolean>(Object::Boolean{.value = false}));
+}
+
+TEST(ReadObjectTest, ReadInvalidBooleanFailure) {
+  std::string content = absl::StrCat("fake", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(
+      ReadObject(input, input.pos(), obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Failed to parse boolean. Invalid value: fake")));
+}
+
+TEST(ReadObjectTest, ReadValidInteger) {
+  std::string content =
+      absl::StrCat("123   43445 +17   -98 0", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = 123}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = 43445}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = 17}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = -98}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = 0}));
+}
+
+TEST(ReadObjectTest, ReadInvalidIntegerFailure) {
+  std::string content = absl::StrCat("-123+123", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(
+      ReadObject(input, input.pos(), obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Failed to parse integer. Invalid value: -123+123")));
+}
+
+TEST(ReadObjectTest, ReadDelimiterTerminatedInteger) {
+  std::string content = absl::StrCat("123(", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = 123}));
+}
+
+TEST(ReadObjectTest, ReadWhiteSpaceTerminatedInteger) {
+  std::string content = absl::StrCat("123\t", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = 123}));
+}
+
+TEST(ReadObjectTest, ReadRealNumberRawValue) {
+  std::string content =
+      absl::StrCat("34.5  -3.62 +123.6    4.  -.002 0.0", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
+                             Object::RealNumber{.raw_value = "34.5"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
+                             Object::RealNumber{.raw_value = "-3.62"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
+                             Object::RealNumber{.raw_value = "+123.6"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
+                             Object::RealNumber{.raw_value = "4."}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
+                             Object::RealNumber{.raw_value = "-.002"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
+                             Object::RealNumber{.raw_value = "0.0"}));
+}
+
+TEST(ReadObjectTest, ReadLiteralStringRawValue) {
+  std::string content = R"((This is a string)
+(Strings can contain newlines
+and such.)
+(Strings can contain balanced parentheses ()
+and special characters ( * ! & } ^ %and so on) .)
+(The following is an empty string .)
+()
+(It has zero (0) length.))";
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::LiteralString>(
+                  Object::LiteralString{.raw_value = R"(This is a string)"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::LiteralString>(Object::LiteralString{
+                  .raw_value = R"(Strings can contain newlines
+and such.)"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::LiteralString>(Object::LiteralString{
+                  .raw_value = R"(Strings can contain balanced parentheses ()
+and special characters ( * ! & } ^ %and so on) .)"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::LiteralString>(Object::LiteralString{
+                  .raw_value = R"(The following is an empty string .)"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>(
+                             Object::LiteralString{.raw_value = R"()"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::LiteralString>(Object::LiteralString{
+                  .raw_value = R"(It has zero (0) length.)"}));
+}
+
+TEST(ReadObjectTest, ReadHexadecimalStringRawValue) {
+  std::string content = "<4E6F762073686D6F7A206B6120706F702E> <901FA3> <901FA>";
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::HexadecimalString>(Object::HexadecimalString{
+                  .raw_value = "4E6F762073686D6F7A206B6120706F702E"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>(
+                             Object::HexadecimalString{.raw_value = "901FA3"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>(
+                             Object::HexadecimalString{.raw_value = "901FA"}));
+}
+
+TEST(ReadObjectTest, ReadName) {
+  std::string content = absl::StrCat(
+      "/Name1 /ASomewhatLongerName /A;Name_With-Various***Characters? /1.2 "
+      "/$$ "
+      "/@pattern /.notdef /Lime#20Green /paired#28#29parentheses "
+      "/The_Key_of_F#23_Minor /A#42",
+      kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "Name1"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::Name>(
+                             Object::Name{.value = "ASomewhatLongerName"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{
+                             .value = "A;Name_With-Various***Characters?"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "1.2"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "$$"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "@pattern"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = ".notdef"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "Lime Green"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::Name>(
+                             Object::Name{.value = "paired()parentheses"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::Name>(
+                             Object::Name{.value = "The_Key_of_F#_Minor"}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "AB"}));
+}
+
+TEST(ReadObjectTest, ReadDelimiterTerminatedName) {
+  std::string content = absl::StrCat("/Name]", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "Name"}));
+}
+
+TEST(ReadObjectTest, ReadWhiteSpaceTerminatedName) {
+  std::string content = absl::StrCat("/Name\n", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "Name"}));
+}
+
+TEST(ReadObjectTest, ReadArray) {
+  std::string content =
+      absl::StrCat("[549 3.14 false (Ralph) /SomeName]", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(
+      obj.value,
+      VariantWith<Object::Array>(Object::Array{
+          .objects = {
+              Object{.value = Object::Integer{.value = 549}},
+              Object{.value = Object::RealNumber{.raw_value = "3.14"}},
+              Object{.value = Object::Boolean{.value = false}},
+              Object{.value = Object::LiteralString{.raw_value = "Ralph"}},
+              Object{.value = Object::Name{.value = "SomeName"}}}}));
+}
+
+TEST(ReadObjectTest, ReadArrayNested) {
+  std::string content = absl::StrCat("[549 [3.14]]", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(
+      obj.value,
+      VariantWith<Object::Array>(Object::Array{
+          .objects = {
+              Object{.value = Object::Integer{.value = 549}},
+              Object{
+                  .value = Object::Array{.objects = {Object{
+                                             .value =
+                                                 Object::RealNumber{
+                                                     .raw_value = "3.14"}}}},
+              }}}));
+}
+
+TEST(ReadObjectTest, ReadArrayWithComments) {
+  std::string content =
+      absl::StrCat("[549 %comment \n 3.14 % comment \n]", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(
+      obj.value,
+      VariantWith<Object::Array>(Object::Array{
+          .objects = {
+              Object{.value = Object::Integer{.value = 549}},
+              Object{.value = Object::RealNumber{.raw_value = "3.14"}}}}));
+}
+
+TEST(ReadObjectTest, ReadDictionary) {
+  std::string content =
+      absl::StrCat("<</Type /Example /Subtype /Table>>", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+
+  Object expected_obj;
+  Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>();
+  dict.entries = {{"Type", Object{.value = Object::Name{.value = "Example"}}},
+                  {"Subtype", Object{.value = Object::Name{.value = "Table"}}}};
+  EXPECT_EQ(obj, expected_obj);
+}
+
+TEST(ReadObjectTest, ReadDictionaryWithComments) {
+  std::string content = absl::StrCat(
+      "<</Type /Example %comment \n /Subtype /Table>>", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+
+  Object expected_obj;
+  Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>();
+  dict.entries = {{"Type", Object{.value = Object::Name{.value = "Example"}}},
+                  {"Subtype", Object{.value = Object::Name{.value = "Table"}}}};
+  EXPECT_EQ(obj, expected_obj);
+}
+
+TEST(ReadObjectTest, CommentsTreatedAsDelimiters) {
+  std::string content = absl::StrCat(R"(/abc%comment (/%) blah blah blah
+        123)",
+                                     kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "abc"}));
+
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = 123}));
+}
+
+TEST(ReadObjectTest, ReadDictionaryEmpty) {
+  std::string content = absl::StrCat("<<>>", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+
+  Object expected_obj = Object{.value = Object::Dictionary{}};
+  EXPECT_EQ(obj, expected_obj);
+}
+
+TEST(ReadObjectTest, ReadDictionaryNested) {
+  std::string content =
+      absl::StrCat("<</Subdictionary << /Item1 0.4 >> >>", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+
+  Object expected_obj;
+  Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>();
+  dict.entries = {
+      {"Subdictionary",
+       Object{.value = Object::Dictionary{
+                  .entries = {{"Item1", Object{.value = Object::RealNumber{
+                                                   .raw_value = "0.4"}}}}}}}};
+  EXPECT_EQ(obj, expected_obj);
+}
+
+TEST(ReadObjectTest, ReadValidNull) {
+  std::string content = "null";
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value, VariantWith<Object::Null>(Object::Null{}));
+}
+
+TEST(ReadObjectTest, ReadInvalidNullFailure) {
+  std::string content = "nULL";
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(ReadObject(input, input.pos(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Invalid value: nULL")));
+}
+
+TEST(ReadObjectTest, ReadIndirectReference) {
+  std::string content = absl::StrCat("123 321 R", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::IndirectReference>(Object::IndirectReference{
+                  .object_number = 123, .generation_number = 321}));
+}
+
+TEST(ReadObjectTest, IndirectReferencePatternMatchesBeginningOfString) {
+  std::string content = absl::StrCat("123 /Root 1 2 R", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, /*obj_offset=*/0, obj), IsOk());
+  // "123 /Root 1 2 R ...." should not be matched as an indirect reference, the
+  // preceding integer should be read instead.
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Integer>(Object::Integer{.value = 123}));
+  ASSERT_THAT(ReadObject(input, /*obj_offset=*/10, obj), IsOk());
+  // "1 2 R ..." should be matched as an indirect reference.
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::IndirectReference>(Object::IndirectReference{
+                  .object_number = 1, .generation_number = 2}));
+}
+
+TEST(ReadObjectTest, ReadIndirectReferenceAdvanceInputStreamPositionCorrectly) {
+  std::string content = absl::StrCat("123 321 R /Name", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::IndirectReference>(Object::IndirectReference{
+                  .object_number = 123, .generation_number = 321}));
+  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  EXPECT_THAT(obj.value,
+              VariantWith<Object::Name>(Object::Name{.value = "Name"}));
+}
+
+TEST(ReadObjectTest, ReadStreamNotAllowed) {
+  std::string content = absl::StrCat("stream endstream", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(
+      ReadObject(input, input.pos(), obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Stream must reside in an indirect object. Call "
+                         "ReadIndirectObject() instead.")));
+}
+
+TEST(ReadObjectTest, ReadMalformedStreamKeywordFailure) {
+  std::string content = absl::StrCat("steam endstream", kContentPadding);
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(
+      ReadObject(input, input.pos(), obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Malformed keyword. Expected 'stream' but got")));
+}
+
+TEST(ReadIndirectObjectTest, ReadIndirectObject) {
+  std::string content = R"(12 0 obj
+(Brillig)
+endobj)";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+              IsOk());
+  EXPECT_EQ(obj.object_number, 12);
+  EXPECT_EQ(obj.generation_number, 0);
+  EXPECT_THAT(obj.object.value,
+              VariantWith<Object::LiteralString>(
+                  Object::LiteralString{.raw_value = "Brillig"}));
+}
+
+TEST(ReadIndirectObjectTest, ReadInvalidObjectNumberFailure) {
+  std::string content = "1+2 0 obj <A0E2FA> endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(
+      ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Failed to parse object number. Invalid value: 1+2")));
+}
+
+TEST(ReadIndirectObjectTest, ReadInvalidGenerationNumberFailure) {
+  std::string content = "12 0.0 obj <A0E2FA> endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(
+      ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr("Failed to parse generation number. Invalid value: 0.0")));
+}
+
+TEST(ReadIndirectObjectTest, ReadMalformedKeywordFailure) {
+  std::string content = "12 0 R <A0E2FA> endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Malformed keyword. Expected 'obj' but got")));
+}
+
+TEST(ReadIndirectObjectTest, ReadMalformedKeywordFailure2) {
+  std::string content = "12 0 obj <A0E2FA> objend";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(
+      ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Malformed keyword. Expected 'endobj' but got")));
+}
+
+TEST(ReadIndirectObjectTest, ReadStreamWithCrlfMarker) {
+  std::string content =
+      "1 2 obj <</Length 10>> stream\r\n1234567890\r\nendstream endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+              IsOk());
+  EXPECT_EQ(obj.object_number, 1);
+  EXPECT_EQ(obj.generation_number, 2);
+  EXPECT_THAT(obj.stream_dictionary.value().entries,
+              UnorderedElementsAre(Pair(
+                  "Length", Object{.value = Object::Integer{.value = 10}})));
+  EXPECT_THAT(obj.object.value, VariantWith<Object::Stream>(
+                                    Object::Stream{.raw_value = "1234567890"}));
+}
+
+TEST(ReadIndirectObjectTest, ReadStreamWithLfMarker) {
+  std::string content =
+      "1 2 obj <</Length 10>> stream\n1234567890\nendstream endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+              IsOk());
+  EXPECT_EQ(obj.object_number, 1);
+  EXPECT_EQ(obj.generation_number, 2);
+  EXPECT_THAT(obj.stream_dictionary.value().entries,
+              UnorderedElementsAre(Pair(
+                  "Length", Object{.value = Object::Integer{.value = 10}})));
+  EXPECT_THAT(obj.object.value, VariantWith<Object::Stream>(
+                                    Object::Stream{.raw_value = "1234567890"}));
+}
+
+TEST(ReadIndirectObjectTest, ReadStreamWithCrMarkerFailure) {
+  std::string content =
+      "1 2 obj <</Length 10>> stream\r1234567890\rendstream endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(
+      ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Malformed stream. Expected '\r\n' or '\n' but got")));
+}
+
+TEST(ReadIndirectObjectTest, ReadStreamUnspecifiedLengthFailure) {
+  std::string content = "1 2 obj <<>> stream\r\n1234567890\r\nendstream endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(
+      ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+      StatusIs(
+          absl::StatusCode::kDataLoss,
+          HasSubstr("Stream dictionary does not contain a 'Length' entry.")));
+}
+
+TEST(ReadIndirectObjectTest, ReadStreamByChunks) {
+  std::string content =
+      "1 2 obj <</Length 50>> "
+      "stream\r\n12345678901234567890123456789012345678901234567890\r\nendstrea"
+      "m endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+              IsOk());
+  EXPECT_EQ(obj.object_number, 1);
+  EXPECT_EQ(obj.generation_number, 2);
+  EXPECT_THAT(obj.stream_dictionary.value().entries,
+              UnorderedElementsAre(Pair(
+                  "Length", Object{.value = Object::Integer{.value = 50}})));
+  EXPECT_THAT(
+      obj.object.value,
+      VariantWith<Object::Stream>(Object::Stream{
+          .raw_value = "12345678901234567890123456789012345678901234567890"}));
+}
+
+TEST(ReadIndirectObjectTest, ReadStreamOversizedLengthFails) {
+  std::string content =
+      "1 2 obj <</Length 11000000>> "
+      "stream\r\n123\r\nendstream endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(
+      ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Stream length exceeds maximum allowed size")));
+}
+
+TEST(ReadIndirectObjectTest, IndirectReferenceResolved) {
+  std::string content =
+      "1 2 obj <</Length 3 4 R>> stream\r\n1234567890\r\nendstream endobj";
+  riegeli::StringReader<> input(content);
+  auto mock_resolver =
+      MockResolver({.object_number = 3,
+                    .generation_number = 4,
+                    .object = Object{.value = Object::Integer{.value = 10}}});
+  IndirectObject obj;
+  ASSERT_THAT(
+      ReadIndirectObject(input, input.pos(), std::move(mock_resolver), obj),
+      IsOk());
+  EXPECT_EQ(obj.object_number, 1);
+  EXPECT_EQ(obj.generation_number, 2);
+  EXPECT_THAT(
+      obj.stream_dictionary.value().entries,
+      UnorderedElementsAre(Pair(
+          "Length", Object{.value = Object::IndirectReference{
+                               .object_number = 3, .generation_number = 4}})));
+  EXPECT_THAT(obj.object.value, VariantWith<Object::Stream>(
+                                    Object::Stream{.raw_value = "1234567890"}));
+}
+
+TEST(ReadIndirectObjectTest, ReadStreamMalformedEndstreamKeywordFailure) {
+  std::string content =
+      "1 2 obj <</Length 10>> stream\r\n1234567890\r\nendstram endobj";
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(
+      ReadIndirectObject(input, input.pos(), MockResolver(), obj),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "Malformed keyword. Expected 'endstream' but got 'endstram '")));
+}
+
+TEST(SkipOverWhiteSpacesTest, SkipsOverLeadingWhiteSpaces) {
+  std::string content =
+      absl::StrCat("\x09\x0a\x0c\x0d\x20", "abc", kContentPadding);
+  riegeli::StringReader<> input(content);
+
+  EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk());
+  EXPECT_EQ(input.pos(), 5);
+}
+
+TEST(SkipOverWhiteSpacesTest, NoLeadingWhiteSpaces) {
+  std::string content = absl::StrCat("abc", kContentPadding);
+  riegeli::StringReader<> input(content);
+
+  EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk());
+  EXPECT_EQ(input.pos(), 0);
+}
+
+TEST(SkipOverWhiteSpacesTest, OnlyWhiteSpacesFailure) {
+  std::string content = "\t\n\r  ";
+  riegeli::StringReader<> input(content);
+
+  EXPECT_THAT(SkipOverWhiteSpaces(input),
+              StatusIs(absl::StatusCode::kDataLoss));
+}
+
+TEST(SkipOverWhiteSpacesTest, EmptyInputFailure) {
+  std::string content = "";
+  riegeli::StringReader<> input(content);
+
+  EXPECT_THAT(SkipOverWhiteSpaces(input),
+              StatusIs(absl::StatusCode::kDataLoss));
+}
+
+TEST(SkipOverWhiteSpacesTest, SkipsOverComments) {
+  std::string content = "% comment\nabc";
+  riegeli::StringReader<> input(content);
+
+  EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk());
+  EXPECT_EQ(input.pos(), 10);
+}
+
+TEST(SkipOverWhiteSpacesTest, SkipsOverMultipleComments) {
+  std::string content = "% comment 1\n% comment 2\nabc";
+  riegeli::StringReader<> input(content);
+
+  EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk());
+  EXPECT_EQ(input.pos(), 24);
+}
+
+TEST(SkipOverWhiteSpacesTest, SkipsOverCommentsMixedWithWhitespaces) {
+  std::string content = "  % comment \n  abc";
+  riegeli::StringReader<> input(content);
+
+  EXPECT_THAT(SkipOverWhiteSpaces(input), IsOk());
+  EXPECT_EQ(input.pos(), 15);
+}
+
+TEST(ReadObjectTest, DeeplyNestedArrayFails) {
+  std::string content(102, '[');
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(ReadObject(input, input.pos(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Max nesting depth exceeded")));
+}
+
+TEST(ReadObjectTest, DeeplyNestedDictionaryFails) {
+  std::string content;
+  for (int i = 0; i < 102; ++i) {
+    content += "<</K ";
+  }
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(ReadObject(input, input.pos(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Max nesting depth exceeded")));
+}
+
+TEST(ReadObjectTest, UnboundedNumberTokenExceedsLimitFails) {
+  std::string content = std::string(130, '1');
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(ReadObject(input, input.pos(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("exceeds maximum allowed length")));
+}
+
+TEST(ReadObjectTest, UnboundedLiteralStringExceedsLimitFails) {
+  std::string content = "(" + std::string(1024 * 1024 * 10 + 5, 'a');
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(ReadObject(input, input.pos(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("exceeds maximum allowed length")));
+}
+
+TEST(ReadObjectTest, UnboundedHexadecimalStringExceedsLimitFails) {
+  std::string content = "<" + std::string(1024 * 1024 * 10 + 5, 'a');
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(ReadObject(input, input.pos(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("exceeds maximum allowed length")));
+}
+
+TEST(ReadObjectTest, UnboundedNameTokenExceedsLimitFails) {
+  std::string content = "/" + std::string(4100, 'a');
+  riegeli::StringReader<> input(content);
+  Object obj;
+  EXPECT_THAT(ReadObject(input, input.pos(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("exceeds maximum allowed length")));
+}
+
+TEST(ReadIndirectObjectTest, UnboundedObjectNumberExceedsLimitFails) {
+  std::string content = std::string(130, '1');
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(ReadIndirectObject(input, 0, MockResolver(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("exceeds maximum allowed length")));
+}
+
+TEST(ReadIndirectObjectTest, UnboundedGenerationNumberExceedsLimitFails) {
+  std::string content = "1 " + std::string(130, '1');
+  riegeli::StringReader<> input(content);
+  IndirectObject obj;
+  EXPECT_THAT(ReadIndirectObject(input, 0, MockResolver(), obj),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("exceeds maximum allowed length")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/pdf/objects.h b/formats/pdf/objects.h
new file mode 100644
index 0000000..4b19df5
--- /dev/null
+++ b/formats/pdf/objects.h
@@ -0,0 +1,277 @@
+// 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_FORMATS_PDF_OBJECTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECTS_H_
+
+#include <cstdint>
+#include <cstdlib>
+#include <memory>
+#include <optional>
+#include <ostream>
+#include <sstream>
+#include <string>
+#include <variant>
+#include <vector>
+
+#ifdef __GNUG__
+#include <cxxabi.h>  // IWYU pragma: keep
+#endif
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+
+namespace credentio {
+
+namespace internal {
+inline std::string Demangle(const char* mangled) {
+#ifdef __GNUG__
+  int status = 0;
+  std::unique_ptr<char, void (*)(void*)> res{
+      abi::__cxa_demangle(mangled, nullptr, nullptr, &status), std::free};
+  return (status == 0) ? res.get() : mangled;
+#else
+  return mangled;
+#endif
+}
+
+template <typename T>
+std::string Stringify(const T& val) {
+  std::ostringstream ss;
+  ss << val;
+  return ss.str();
+}
+}  // namespace internal
+
+struct Object;
+
+// Represents any PDF object.
+// Uses std::variant to hold one of the possible PDF object types.
+// See ISO 32000-2 Section 7.3.2 through 7.3.9 for more details.
+struct Object {
+  // Represents a PDF boolean object.
+  struct Boolean {
+    bool value;
+    friend bool operator==(const Boolean&, const Boolean&) = default;
+    friend std::ostream& operator<<(std::ostream& os, const Boolean& b) {
+      return os << (b.value ? "true" : "false");
+    }
+  };
+
+  // Represents a PDF integer object.
+  struct Integer {
+    int64_t value;
+    friend bool operator==(const Integer&, const Integer&) = default;
+    friend std::ostream& operator<<(std::ostream& os, const Integer& i) {
+      return os << i.value;
+    }
+  };
+
+  // Represents a PDF real number object.
+  struct RealNumber {
+    std::string raw_value;
+    friend bool operator==(const RealNumber&, const RealNumber&) = default;
+    friend std::ostream& operator<<(std::ostream& os, const RealNumber& r) {
+      return os << r.raw_value;
+    }
+  };
+
+  // Represents a PDF literal string object.
+  struct LiteralString {
+    std::string raw_value;
+    friend bool operator==(const LiteralString&,
+                           const LiteralString&) = default;
+    friend std::ostream& operator<<(std::ostream& os, const LiteralString& s) {
+      return os << "(" << s.raw_value << ")";
+    }
+  };
+
+  // Represents a PDF hexadecimal string object.
+  struct HexadecimalString {
+    std::string raw_value;
+    friend bool operator==(const HexadecimalString&,
+                           const HexadecimalString&) = default;
+    friend std::ostream& operator<<(std::ostream& os,
+                                    const HexadecimalString& s) {
+      return os << "<" << s.raw_value << ">";
+    }
+  };
+
+  // Represents a PDF name object.
+  struct Name {
+    std::string value;
+    friend bool operator==(const Name&, const Name&) = default;
+    friend std::ostream& operator<<(std::ostream& os, const Name& n) {
+      return os << "/" << n.value;
+    }
+  };
+
+  // Represents a PDF array object.
+  struct Array {
+    std::vector<Object> objects;
+    friend bool operator==(const Array&, const Array&);
+    friend std::ostream& operator<<(std::ostream& os, const Array& a);
+  };
+
+  // Represents a PDF dictionary object.
+  struct Dictionary {
+    absl::flat_hash_map<std::string, Object> entries;
+    friend bool operator==(const Dictionary&, const Dictionary&);
+    friend std::ostream& operator<<(std::ostream& os, const Dictionary& d);
+  };
+
+  // Represents a PDF stream object.
+  struct Stream {
+    std::string raw_value;
+    friend bool operator==(const Stream&, const Stream&) = default;
+    friend std::ostream& operator<<(std::ostream& os, const Stream& s) {
+      return os << "stream\n" << s.raw_value << "\nendstream";
+    }
+  };
+
+  // Represents a PDF null object.
+  struct Null {
+    friend bool operator==(const Null&, const Null&) = default;
+    friend std::ostream& operator<<(std::ostream& os, const Null& n) {
+      return os << "null";
+    }
+  };
+
+  // Represents a reference to a PDF indirect object.
+  struct IndirectReference {
+    uint32_t object_number;
+    uint32_t generation_number;
+    friend bool operator==(const IndirectReference&,
+                           const IndirectReference&) = default;
+    friend auto operator<=>(const IndirectReference&,
+                            const IndirectReference&) = default;
+    friend std::ostream& operator<<(std::ostream& os,
+                                    const IndirectReference& r) {
+      return os << r.object_number << " " << r.generation_number << " R";
+    }
+  };
+
+  std::variant<Boolean, Integer, RealNumber, LiteralString, HexadecimalString,
+               Name, Array, Dictionary, Stream, Null, IndirectReference>
+      value;
+
+  friend bool operator==(const Object& lhs, const Object& rhs) {
+    return lhs.value == rhs.value;
+  }
+  friend std::ostream& operator<<(std::ostream& os, const Object& obj) {
+    std::visit([&os](const auto& alternative) { os << alternative; },
+               obj.value);
+    return os;
+  }
+
+  // Returns a copy of the value of type `T` held by `obj.value`. Returns an
+  // `InvalidArgumentError` if `obj.value` is not of type `T`.
+  template <typename T>
+  static absl::StatusOr<T> As(const Object& obj) {
+    if (!std::holds_alternative<T>(obj.value)) {
+      constexpr size_t kMaxErrorStringLen = 256;
+      std::string str = internal::Stringify(obj);
+      if (str.size() > kMaxErrorStringLen) {
+        str = absl::StrCat(absl::string_view(str).substr(0, kMaxErrorStringLen),
+                           "...");
+      }
+      return absl::InvalidArgumentError(absl::StrCat(
+          "Object is not a ", internal::Demangle(typeid(T).name()), ": ", str));
+    }
+    return std::get<T>(obj.value);
+  }
+};
+
+// Define these after Object is fully defined because they use Object.
+inline bool operator==(const Object::Array& lhs, const Object::Array& rhs) {
+  return lhs.objects == rhs.objects;
+}
+
+inline bool operator==(const Object::Dictionary& lhs,
+                       const Object::Dictionary& rhs) {
+  return lhs.entries == rhs.entries;
+}
+
+inline std::ostream& operator<<(std::ostream& os, const Object::Array& a) {
+  static thread_local int depth = 0;
+  if (depth >= 100) {
+    return os << "[...]";
+  }
+  ++depth;
+  os << "[";
+  for (size_t i = 0; i < a.objects.size(); ++i) {
+    if (i > 0) os << " ";
+    os << a.objects[i];
+  }
+  os << "]";
+  --depth;
+  return os;
+}
+
+inline std::ostream& operator<<(std::ostream& os, const Object::Dictionary& d) {
+  static thread_local int depth = 0;
+  if (depth >= 100) {
+    return os << "<<...>>";
+  }
+  ++depth;
+  os << "<<";
+  for (const auto& [key, val] : d.entries) {
+    os << "/" << key << " " << val;
+  }
+  os << ">>";
+  --depth;
+  return os;
+}
+
+// Represents a PDF indirect object.
+// See ISO 32000-2 Section 7.3.10 for more details.
+struct IndirectObject {
+  uint32_t object_number;
+  uint32_t generation_number;
+
+  // Required if `object` is a stream.
+  std::optional<Object::Dictionary> stream_dictionary;
+
+  Object object;
+
+  friend bool operator==(const IndirectObject&,
+                         const IndirectObject&) = default;
+  friend std::ostream& operator<<(std::ostream& os, const IndirectObject& obj) {
+    os << obj.object_number << " " << obj.generation_number << " obj\n";
+    if (obj.stream_dictionary.has_value()) {
+      os << *obj.stream_dictionary << "\n";
+    }
+    os << obj.object << "\nendobj";
+    return os;
+  }
+};
+
+template <typename T>
+absl::StatusOr<T> FindObjectInDictionary(const Object::Dictionary& dict,
+                                         absl::string_view key) {
+  const auto it = dict.entries.find(key);
+  if (it == dict.entries.end()) {
+    return absl::NotFoundError(
+        absl::Substitute("Dictionary does not contain a '$0' entry.", key));
+  }
+  return Object::As<T>(it->second);
+}
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECTS_H_
diff --git a/formats/pdf/reader.cc b/formats/pdf/reader.cc
new file mode 100644
index 0000000..dbe3973
--- /dev/null
+++ b/formats/pdf/reader.cc
@@ -0,0 +1,289 @@
+// 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 "formats/pdf/reader.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/container/btree_map.h"
+#include "absl/container/btree_set.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/memory/memory.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/substitute.h"
+#include "formats/pdf/object_reader.h"
+#include "formats/pdf/objects.h"
+#include "re2/re2.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+// Matches the cross-reference table offset in the trailer. See ISO 32000-2
+// Section 7.5.5
+static constexpr LazyRE2 kXrefOffsetPattern = {R"(startxref\s(\d+)\s%%EOF)"};
+
+// Matches a single cross-reference entry in the cross-reference table. See ISO
+// 32000-2 Section 7.5.4
+static constexpr LazyRE2 kXrefEntryPattern = {
+    R"(^(\d{10}) (\d{5}) ([nf])(?: \r| \n|\r\n))"};
+
+// Reads the first cross-reference table offset from the file trailer.
+absl::StatusOr<uint64_t> GetFirstXrefTableOffset(riegeli::Reader& input) {
+  // Scan the last 30 bytes for the cross-reference table offset. This allows
+  // for the "startxref" keyword and an offset value large enough for a
+  // multi-terabyte file.
+  if (!input.Seek(input.Size().value_or(30) - 30)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+
+  std::string buffer;
+  if (!input.Read(30, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  uint64_t offset;
+  if (!RE2::PartialMatch(buffer, *kXrefOffsetPattern, &offset)) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Failed to find cross-reference table offset in the "
+                     "following payload: ",
+                     buffer));
+  }
+  return offset;
+}
+
+// Reads the offset to the next cross-reference table from the file trailer.
+// Returns `std::nullopt` if the file trailer dictionary does not contain a
+// "Prev" entry.
+absl::StatusOr<std::optional<uint64_t>> GetPrevXrefTableOffset(
+    const Object::Dictionary& file_trailer_dict) {
+  if (const auto it = file_trailer_dict.entries.find("Prev");
+      it != file_trailer_dict.entries.end()) {
+    ABSL_ASSIGN_OR_RETURN(const auto prev_xref_offset,
+                          Object::As<Object::Integer>(it->second));
+    return prev_xref_offset.value;
+  }
+  return std::nullopt;
+}
+
+// Reads the standard cross-reference table and the file trailer from the input
+// stream at the given offset.
+absl::Status ReadXrefTableAndTrailer(
+    riegeli::Reader& input, uint64_t xref_offset,
+    absl::btree_map<Object::IndirectReference, uint64_t>& xref_table,
+    absl::btree_set<Object::IndirectReference>& free_entries,
+    Object::Dictionary& file_trailer_dict) {
+  if (!input.Seek(xref_offset)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+
+  std::string buffer;
+  if (!input.Read(4, buffer)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read buffer"));
+  }
+  if (buffer != "xref") {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Malformed cross-reference table. Expected 'xref' but got "
+                     "'",
+                     buffer, "'"));
+  }
+  while (true) {
+    ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+    if (!input.Read(7, buffer)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to read buffer"));
+    }
+    if (buffer == "trailer") {
+      break;
+    }
+    if (!input.Seek(input.pos() - 7)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek to offset"));
+    }
+
+    Object int_obj;
+    ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), int_obj));
+    ABSL_ASSIGN_OR_RETURN(const auto obj_num,
+                          Object::As<Object::Integer>(int_obj));
+
+    ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), int_obj));
+    ABSL_ASSIGN_OR_RETURN(const auto obj_count,
+                          Object::As<Object::Integer>(int_obj));
+
+    ABSL_RETURN_IF_ERROR(SkipOverWhiteSpaces(input));
+
+    for (uint32_t i = 0; i < obj_count.value; ++i) {
+      // Each cross-reference entry is expected to be exactly 20 bytes long.
+      if (!input.Read(20, buffer)) {
+        return input.StatusOrAnnotate(
+            absl::DataLossError("Failed to read buffer"));
+      }
+      uint64_t obj_offset;
+      uint32_t gen_num;
+      std::string type;
+      if (RE2::PartialMatch(buffer, *kXrefEntryPattern, &obj_offset, &gen_num,
+                            &type)) {
+        auto xref_entry_key = Object::IndirectReference{
+            .object_number = static_cast<uint32_t>(obj_num.value + i),
+            .generation_number = gen_num};
+        if (type == "n") {  // 'n' represents an in-use entry.
+          xref_table.insert({xref_entry_key, obj_offset});
+        } else {  // 'f' represents a free entry.
+          free_entries.insert(xref_entry_key);
+        }
+      } else {
+        return absl::InvalidArgumentError(
+            absl::StrCat("Failed to parse cross-reference table entry. Invalid "
+                         "value: ",
+                         buffer));
+      }
+    }
+  }
+  Object dict_obj;
+  ABSL_RETURN_IF_ERROR(ReadObject(input, input.pos(), dict_obj));
+  ABSL_ASSIGN_OR_RETURN(file_trailer_dict,
+                        Object::As<Object::Dictionary>(dict_obj));
+
+  return absl::OkStatus();
+}
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<PdfReader>> PdfReader::Create(
+    riegeli::Reader* absl_nonnull input) {
+  if (input == nullptr) {
+    return absl::InvalidArgumentError("input cannot be null");
+  }
+  absl::flat_hash_set<uint64_t> xref_offsets_visited;
+  ABSL_ASSIGN_OR_RETURN(uint64_t xref_offset, GetFirstXrefTableOffset(*input));
+  xref_offsets_visited.insert(xref_offset);
+
+  absl::btree_map<Object::IndirectReference, uint64_t> xref_table;
+  absl::btree_set<Object::IndirectReference> free_entries;
+  Object::Dictionary file_trailer_dict;
+  ABSL_RETURN_IF_ERROR(ReadXrefTableAndTrailer(
+      *input, xref_offset, xref_table, free_entries, file_trailer_dict));
+
+  ABSL_ASSIGN_OR_RETURN(std::optional<uint64_t> prev_xref_offset,
+                        GetPrevXrefTableOffset(file_trailer_dict));
+  while (prev_xref_offset.has_value()) {
+    if (xref_offsets_visited.contains(*prev_xref_offset)) {
+      return absl::InvalidArgumentError(
+          "Initialization failed: Circular cross-reference table detected.");
+    }
+    xref_offsets_visited.insert(*prev_xref_offset);
+
+    absl::btree_map<Object::IndirectReference, uint64_t> prev_xref_table;
+    absl::btree_set<Object::IndirectReference> prev_free_entries;
+    Object::Dictionary prev_file_trailer_dict;
+    ABSL_RETURN_IF_ERROR(
+        ReadXrefTableAndTrailer(*input, *prev_xref_offset, prev_xref_table,
+                                prev_free_entries, prev_file_trailer_dict));
+    ABSL_ASSIGN_OR_RETURN(prev_xref_offset,
+                          GetPrevXrefTableOffset(prev_file_trailer_dict));
+
+    xref_table.merge(prev_xref_table);
+    if (!prev_xref_table.empty()) {
+      return absl::InvalidArgumentError(
+          "Initialization failed: Collision detected in cross-reference "
+          "table for in-use entries.");
+    }
+    free_entries.merge(prev_free_entries);
+    if (!prev_free_entries.empty()) {
+      return absl::InvalidArgumentError(
+          "Initialization failed: Collision detected in cross-reference "
+          "table for free entries.");
+    }
+  }
+  auto reader = absl::WrapUnique(new PdfReader(input, std::move(xref_table),
+                                               std::move(free_entries),
+                                               std::move(file_trailer_dict)));
+  return reader;
+}
+
+bool PdfReader::HasNext() { return current_it_ != xref_table_.end(); }
+
+absl::StatusOr<IndirectObject> PdfReader::Next() {
+  if (!HasNext()) {
+    return absl::OutOfRangeError("No more objects to read.");
+  }
+  IndirectObject obj;
+  if (absl::Status status = ReadIndirectObject(
+          input_, current_it_->second,
+          [this](const Object::IndirectReference& ref) {
+            return this->GetObject(ref.object_number, ref.generation_number);
+          },
+          obj);
+      !status.ok()) {
+    return status;
+  }
+  ++current_it_;
+  return obj;
+}
+
+absl::StatusOr<uint64_t> PdfReader::GetObjectOffset(
+    uint32_t object_number, uint32_t generation_number) {
+  auto it = xref_table_.find(Object::IndirectReference{
+      .object_number = object_number, .generation_number = generation_number});
+  if (it == xref_table_.end()) {
+    return absl::NotFoundError(absl::Substitute(
+        "No offset found for object number: $0, generation number: $1",
+        object_number, generation_number));
+  }
+  return it->second;
+}
+
+absl::StatusOr<IndirectObject> PdfReader::GetObject(
+    uint32_t object_number, uint32_t generation_number) {
+  Object::IndirectReference ref{.object_number = object_number,
+                                .generation_number = generation_number};
+  if (active_objects_.contains(ref)) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Circular reference detected for object: ", object_number,
+                     " ", generation_number));
+  }
+
+  struct ActiveObjectGuard {
+    absl::btree_set<Object::IndirectReference>& active_objects;
+    Object::IndirectReference ref;
+    ~ActiveObjectGuard() { active_objects.erase(ref); }
+  };
+  active_objects_.insert(ref);
+  ActiveObjectGuard guard{active_objects_, ref};
+
+  ABSL_ASSIGN_OR_RETURN(uint64_t offset,
+                        GetObjectOffset(object_number, generation_number));
+  IndirectObject obj;
+  if (absl::Status status = ReadIndirectObject(
+          input_, offset,
+          [this](const Object::IndirectReference& ref) {
+            return this->GetObject(ref.object_number, ref.generation_number);
+          },
+          obj);
+      !status.ok()) {
+    return status;
+  }
+  return obj;
+}
+
+}  // namespace credentio
diff --git a/formats/pdf/reader.h b/formats/pdf/reader.h
new file mode 100644
index 0000000..db1e4b5
--- /dev/null
+++ b/formats/pdf/reader.h
@@ -0,0 +1,97 @@
+// 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_FORMATS_PDF_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_READER_H_
+
+#include <cstdint>
+#include <memory>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/container/btree_map.h"
+#include "absl/container/btree_set.h"
+#include "absl/status/statusor.h"
+#include "formats/pdf/objects.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// PDF reader for C2PA, holds onto a reference to a `riegeli::Reader` which
+// must outlive the reader.
+class PdfReader {
+ public:
+  // Creates and initializes a PdfReader.
+  static absl::StatusOr<std::unique_ptr<PdfReader>> Create(
+      riegeli::Reader* absl_nonnull input);
+
+  // Returns true if there is a next PDF object to read.
+  bool HasNext();
+
+  // Reads the next PDF object from the input.
+  absl::StatusOr<IndirectObject> Next();
+
+  // Returns the file trailer dictionary.
+  const Object::Dictionary& file_trailer_dictionary() const {
+    return file_trailer_dictionary_;
+  }
+
+  // Returns the ordered set of free object entries in the cross-reference
+  // table.
+  const absl::btree_set<Object::IndirectReference>& free_entries() const {
+    return free_entries_;
+  }
+
+  // Returns the byte offset of the PDF object with the given object number and
+  // generation number. If the object is not found, returns a NOT_FOUND error.
+  absl::StatusOr<uint64_t> GetObjectOffset(uint32_t object_number,
+                                           uint32_t generation_number);
+
+  // Returns the PDF object with the given object number and generation number.
+  // If the object is not found, returns a NOT_FOUND error.
+  absl::StatusOr<IndirectObject> GetObject(uint32_t object_number,
+                                           uint32_t generation_number);
+
+ private:
+  explicit PdfReader(
+      riegeli::Reader* absl_nonnull input,
+      absl::btree_map<Object::IndirectReference, uint64_t>&& xref_table,
+      absl::btree_set<Object::IndirectReference>&& free_entries,
+      Object::Dictionary&& file_trailer_dict)
+      : input_(*input),
+        xref_table_(std::move(xref_table)),
+        current_it_(xref_table_.begin()),
+        free_entries_(std::move(free_entries)),
+        file_trailer_dictionary_(std::move(file_trailer_dict)) {}
+
+  riegeli::Reader& input_;
+
+  // Cross-reference table mapping in-use object references to their byte
+  // offsets in the input stream.
+  const absl::btree_map<Object::IndirectReference, uint64_t> xref_table_;
+  absl::btree_map<Object::IndirectReference, uint64_t>::const_iterator
+      current_it_;
+
+  // Set of free object entries in the cross-reference table.
+  const absl::btree_set<Object::IndirectReference> free_entries_;
+
+  // File trailer dictionary.
+  const Object::Dictionary file_trailer_dictionary_;
+
+  // Active objects during indirect reference resolution to detect cycles.
+  absl::btree_set<Object::IndirectReference> active_objects_;
+};
+}  // namespace credentio
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_READER_H_
diff --git a/formats/pdf/reader_test.cc b/formats/pdf/reader_test.cc
new file mode 100644
index 0000000..75dbbb7
--- /dev/null
+++ b/formats/pdf/reader_test.cc
@@ -0,0 +1,684 @@
+// 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 "formats/pdf/reader.h"
+
+#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_matchers.h"
+#include "absl/strings/str_replace.h"
+#include "absl/strings/string_view.h"
+#include "formats/pdf/objects.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+using ::testing::Pair;
+using ::testing::UnorderedElementsAre;
+
+#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
+
+Object CreateName(absl::string_view value) {
+  return Object{.value = Object::Name{.value = std::string(value)}};
+}
+
+Object CreateInteger(int64_t value) {
+  return Object{.value = Object::Integer{.value = value}};
+}
+
+Object CreateRealNumber(absl::string_view value) {
+  return Object{.value = Object::RealNumber{.raw_value = std::string(value)}};
+}
+
+Object CreateIndirectRef(uint32_t object_number, uint32_t generation_number) {
+  return Object{.value = Object::IndirectReference{
+                    .object_number = object_number,
+                    .generation_number = generation_number}};
+}
+
+Object CreateArray(std::vector<Object> values) {
+  return Object{.value = Object::Array{.objects = std::move(values)}};
+}
+
+Object CreateStream(absl::string_view raw_value) {
+  return Object{.value = Object::Stream{.raw_value = std::string(raw_value)}};
+}
+
+Object CreateDictionary(absl::flat_hash_map<std::string, Object> entries) {
+  return Object{.value = Object::Dictionary{.entries = std::move(entries)}};
+}
+
+std::string GetTestPdfContent() {
+  std::string content = R"(%PDF-1.7
+%%%%%
+1 0 obj
+/test_name
+endobj
+2 0 obj
+123.456
+endobj
+3 0 obj
+<</Type /Catalog>>
+endobj
+xref
+0 4
+0000000000 65535 f@
+0000000015 00000 n@
+0000000041 00000 n@
+0000000064 00000 n@
+trailer
+<</Root 3 0 R /Size 3>>
+startxref
+98
+%%EOF)";
+  return absl::StrReplaceAll(content, {{"@", " "}});
+}
+
+TEST(PdfReaderTest, ReadsAllObjects) {
+  std::string content = GetTestPdfContent();
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+
+  std::vector<IndirectObject> objects;
+  while (reader->HasNext()) {
+    ASSERT_OK_AND_ASSIGN(auto obj, reader->Next());
+    objects.push_back(std::move(obj));
+  }
+
+  EXPECT_THAT(
+      objects,
+      ElementsAre(IndirectObject{.object_number = 1,
+                                 .generation_number = 0,
+                                 .object = CreateName("test_name")},
+                  IndirectObject{.object_number = 2,
+                                 .generation_number = 0,
+                                 .object = CreateRealNumber("123.456")},
+                  IndirectObject{.object_number = 3,
+                                 .generation_number = 0,
+                                 .object = CreateDictionary(
+                                     {{"Type", CreateName("Catalog")}})}));
+}
+
+TEST(PdfReaderTest, FailsWhenReadingPastAllObjects) {
+  std::string content = GetTestPdfContent();
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+
+  int64_t count = 0;
+  while (reader->HasNext()) {
+    ASSERT_OK_AND_ASSIGN(auto obj, reader->Next());
+    ++count;
+  }
+
+  EXPECT_EQ(count, 3);
+
+  EXPECT_THAT(reader->Next(), StatusIs(absl::StatusCode::kOutOfRange,
+                                       HasSubstr("No more objects to read")));
+}
+
+TEST(PdfReaderTest, GetFileTrailerDictionary) {
+  std::string content = GetTestPdfContent();
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+
+  auto file_trailer_dict = reader->file_trailer_dictionary();
+  EXPECT_THAT(file_trailer_dict.entries,
+              UnorderedElementsAre(Pair("Root", CreateIndirectRef(3, 0)),
+                                   Pair("Size", CreateInteger(3))));
+}
+
+TEST(PdfReaderTest, FreeEntries) {
+  std::string content = R"(%PDF-1.7
+xref
+0 8
+0000000003 65535 f@
+0000000015 00000 n@
+0000000081 00000 n@
+0000000005 00000 f@
+0000000331 00000 n@
+0000000000 00000 f@
+0000000409 00000 n@
+0000000555 00000 n@
+trailer
+<<>>
+startxref
+9
+%%EOF)";
+  content = absl::StrReplaceAll(content, {{"@", " "}});
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+  EXPECT_THAT(reader->free_entries(),
+              ElementsAre(Object::IndirectReference{.object_number = 0,
+                                                    .generation_number = 65535},
+                          Object::IndirectReference{.object_number = 3,
+                                                    .generation_number = 0},
+                          Object::IndirectReference{.object_number = 5,
+                                                    .generation_number = 0}));
+}
+
+TEST(PdfReaderTest, GetObjectOffset) {
+  std::string content = GetTestPdfContent();
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+
+  EXPECT_THAT(
+      reader->GetObjectOffset(/*object_number=*/2, /*generation_number=*/0),
+      IsOkAndHolds(41));
+}
+
+TEST(PdfReaderTest, GetNonExistentObjectOffsetFailure) {
+  std::string content = GetTestPdfContent();
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+
+  EXPECT_THAT(
+      reader->GetObjectOffset(/*object_number=*/10, /*generation_number=*/0),
+      StatusIs(absl::StatusCode::kNotFound, HasSubstr("No offset found")));
+}
+
+TEST(PdfReaderTest, GetSpecificObject) {
+  std::string content = GetTestPdfContent();
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+
+  ASSERT_OK_AND_ASSIGN(auto obj, reader->GetObject(2, 0));
+  EXPECT_THAT(obj, (IndirectObject{.object_number = 2,
+                                   .generation_number = 0,
+                                   .object = CreateRealNumber("123.456")}));
+}
+
+TEST(PdfReaderTest, GetNonExistentObjectFailure) {
+  std::string content = GetTestPdfContent();
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+
+  EXPECT_THAT(reader->GetObject(10, 0), StatusIs(absl::StatusCode::kNotFound,
+                                                 HasSubstr("No offset found")));
+}
+
+TEST(PdfReaderTest, ShortFileCausesInitializationFailure) {
+  std::string content = "file is too short";
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(PdfReader::Create(&input),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("Failed to seek to offset")));
+}
+
+TEST(PdfReaderTest, MissingXrefOffsetCausesInitializationFailure) {
+  std::string content = R"(%PDF-1.7
+xref
+0 1
+0000000000 65536 f
+startxref
+%%EOF)";
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(PdfReader::Create(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Failed to find cross-reference table offset "
+                                 "in the following payload")));
+}
+
+TEST(PdfReaderTest, InvalidXrefOffsetCausesInitializationFailure) {
+  std::string content = R"(%PDF-1.7
+xref
+0 1
+0000000000 65536 f
+startxref
+2
+%%EOF)";
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(
+      PdfReader::Create(&input),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "Malformed cross-reference table. Expected 'xref' but got")));
+}
+
+TEST(PdfReaderTest, MalformedXrefTableCausesInitializationFailure) {
+  std::string content = R"(%PDF-1.7
+xref
+0 2
+0000000000 65536 f
+0000000 00001 n
+startxref
+9
+%%EOF)";
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(
+      PdfReader::Create(&input),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "Failed to parse cross-reference table entry. Invalid value")));
+}
+
+TEST(PdfReaderTest, InvalidPrevXrefOffsetTypeCausesInitializationFailure) {
+  std::string content = R"(%PDF-1.7
+xref
+0 1
+0000000000 65535 f@
+trailer
+<</Prev /NotAnInteger /Size 1>>
+startxref
+9
+%%EOF)";
+  content = absl::StrReplaceAll(content, {{"@", " "}});
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(
+      PdfReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Object is not a credentio::Object::Integer")));
+}
+
+TEST(PdfReaderTest, ContainingPreviousXrefTable) {
+  std::string content = R"(%PDF-1.7
+1 0 obj
+<< /Type /Catalog
+/Pages 2 0 R
+>>
+endobj
+2 0 obj
+<< /Type /Pages
+/Kids [3 0 R]
+/Count 1
+>>
+endobj
+3 0 obj
+<< /Type /Page
+/Parent 2 0 R
+/MediaBox [0 0 100 100]
+/Contents 4 0 R
+>>
+endobj
+4 0 obj
+<< /Length 35 >>
+stream
+BT /F1 12 Tf 35 50 Td (Hello) Tj ET
+endstream
+endobj
+xref
+0 5
+0000000000 65535 f@
+0000000009 00000 n@
+0000000058 00000 n@
+0000000115 00000 n@
+0000000202 00000 n@
+trailer
+<< /Size 5
+/Root 1 0 R
+>>
+startxref
+287
+%%EOF
+5 0 obj
+<< /Type /Catalog
+/Pages 6 0 R
+>>
+endobj
+6 0 obj
+<< /Type /Pages
+/Kids [7 0 R  3 0 R]
+/Count 2
+>>
+endobj
+7 0 obj
+<< /Type /Page
+/Parent 6 0 R
+/MediaBox [0 0 50 50]
+/Contents 8 0 R
+>>
+endobj
+8 0 obj
+<< /Length 35 >>
+stream
+BT /F1 12 Tf 10 20 Td (Hello) Tj ET
+endstream
+endobj
+xref
+5 4
+0000000450 00000 n@
+0000000499 00000 n@
+0000000563 00000 n@
+0000000648 00000 n@
+trailer
+<< /Size 9
+/Root 5 0 R
+/Prev 287
+>>
+startxref
+733
+%%EOF)";
+  // Replace all @ with space to avoid presubmit failing trailing whitespace.
+  content = absl::StrReplaceAll(content, {{"@", " "}});
+
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+
+  std::vector<IndirectObject> objects;
+  while (reader->HasNext()) {
+    ASSERT_OK_AND_ASSIGN(auto obj, reader->Next());
+    objects.push_back(std::move(obj));
+  }
+
+  ASSERT_THAT(
+      objects,
+      ElementsAre(
+          IndirectObject{.object_number = 1,
+                         .generation_number = 0,
+                         .object = CreateDictionary({
+                             {"Type", CreateName("Catalog")},
+                             {"Pages", CreateIndirectRef(2, 0)},
+                         })},
+          IndirectObject{.object_number = 2,
+                         .generation_number = 0,
+                         .object = CreateDictionary({
+                             {"Kids", CreateArray({CreateIndirectRef(3, 0)})},
+                             {"Type", CreateName("Pages")},
+                             {"Count", CreateInteger(1)},
+                         })},
+          IndirectObject{.object_number = 3,
+                         .generation_number = 0,
+                         .object = CreateDictionary({
+                             {"Parent", CreateIndirectRef(2, 0)},
+                             {"Type", CreateName("Page")},
+                             {"MediaBox", CreateArray({
+                                              CreateInteger(0),
+                                              CreateInteger(0),
+                                              CreateInteger(100),
+                                              CreateInteger(100),
+                                          })},
+                             {"Contents", CreateIndirectRef(4, 0)},
+                         })},
+          IndirectObject{
+              .object_number = 4,
+              .generation_number = 0,
+              .stream_dictionary =
+                  Object::Dictionary{
+                      .entries = {{"Length", CreateInteger(35)}}},
+              .object = CreateStream("BT /F1 12 Tf 35 50 Td (Hello) Tj ET")},
+          IndirectObject{.object_number = 5,
+                         .generation_number = 0,
+                         .object = CreateDictionary({
+                             {"Type", CreateName("Catalog")},
+                             {"Pages", CreateIndirectRef(6, 0)},
+                         })},
+          IndirectObject{.object_number = 6,
+                         .generation_number = 0,
+                         .object = CreateDictionary({
+                             {"Kids", CreateArray({CreateIndirectRef(7, 0),
+                                                   CreateIndirectRef(3, 0)})},
+                             {"Type", CreateName("Pages")},
+                             {"Count", CreateInteger(2)},
+                         })},
+          IndirectObject{.object_number = 7,
+                         .generation_number = 0,
+                         .object = CreateDictionary({
+                             {"Parent", CreateIndirectRef(6, 0)},
+                             {"Type", CreateName("Page")},
+                             {"MediaBox", CreateArray({
+                                              CreateInteger(0),
+                                              CreateInteger(0),
+                                              CreateInteger(50),
+                                              CreateInteger(50),
+                                          })},
+                             {"Contents", CreateIndirectRef(8, 0)},
+                         })},
+          IndirectObject{
+              .object_number = 8,
+              .generation_number = 0,
+              .stream_dictionary =
+                  Object::Dictionary{
+                      .entries = {{"Length", CreateInteger(35)}}},
+              .object = CreateStream("BT /F1 12 Tf 10 20 Td (Hello) Tj ET")}));
+}
+
+riegeli::Reader* GetNullReader() { return nullptr; }
+
+TEST(PdfReaderTest, NullInputFailure)
+__attribute__((no_sanitize("nullability"))) {
+  EXPECT_THAT(PdfReader::Create(GetNullReader()),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("input cannot be null")));
+}
+
+TEST(PdfReaderTest, CircularXrefTableFailure) {
+  std::string content = R"(%PDF-1.7
+1 0 obj
+<< /Type /Catalog
+/Pages 2 0 R
+>>
+endobj
+2 0 obj
+<< /Type /Pages
+/Kids [3 0 R]
+/Count 1
+>>
+endobj
+3 0 obj
+<< /Type /Page
+/Parent 2 0 R
+/MediaBox [0 0 100 100]
+/Contents 4 0 R
+>>
+endobj
+4 0 obj
+<< /Length 35 >>
+stream
+BT /F1 12 Tf 35 50 Td (Hello) Tj ET
+endstream
+endobj
+xref
+0 5
+0000000000 65535 f@
+0000000009 00000 n@
+0000000058 00000 n@
+0000000115 00000 n@
+0000000202 00000 n@
+trailer
+<< /Size 5
+/Root 1 0 R
+/Prev 743
+>>
+startxref
+287
+%%EOF
+5 0 obj
+<< /Type /Catalog
+/Pages 6 0 R
+>>
+endobj
+6 0 obj
+<< /Type /Pages
+/Kids [7 0 R  3 0 R]
+/Count 2
+>>
+endobj
+7 0 obj
+<< /Type /Page
+/Parent 6 0 R
+/MediaBox [0 0 50 50]
+/Contents 8 0 R
+>>
+endobj
+8 0 obj
+<< /Length 35 >>
+stream
+BT /F1 12 Tf 10 20 Td (Hello) Tj ET
+endstream
+endobj
+xref
+5 4
+0000000460 00000 n@
+0000000509 00000 n@
+0000000573 00000 n@
+0000000658 00000 n@
+trailer
+<< /Size 9
+/Root 5 0 R
+/Prev 287
+>>
+startxref
+743
+%%EOF)";
+  content = absl::StrReplaceAll(content, {{"@", " "}});
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(PdfReader::Create(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Circular cross-reference table detected")));
+}
+
+TEST(PdfReaderTest, CollisionInUseEntriesFailure) {
+  std::string content = R"(%PDF-1.7
+1 0 obj
+<< /Type /Catalog
+/Pages 2 0 R
+>>
+endobj
+2 0 obj
+<< /Type /Pages
+/Kids [3 0 R]
+/Count 1
+>>
+endobj
+3 0 obj
+<< /Type /Page
+/Parent 2 0 R
+/MediaBox [0 0 100 100]
+/Contents 4 0 R
+>>
+endobj
+4 0 obj
+<< /Length 35 >>
+stream
+BT /F1 12 Tf 35 50 Td (Hello) Tj ET
+endstream
+endobj
+xref
+0 5
+0000000000 65535 f@
+0000000009 00000 n@
+0000000058 00000 n@
+0000000115 00000 n@
+0000000202 00000 n@
+trailer
+<< /Size 5
+/Root 1 0 R
+>>
+startxref
+287
+%%EOF
+5 0 obj
+<< /Type /Catalog
+/Pages 6 0 R
+>>
+endobj
+6 0 obj
+<< /Type /Pages
+/Kids [7 0 R  3 0 R]
+/Count 2
+>>
+endobj
+7 0 obj
+<< /Type /Page
+/Parent 6 0 R
+/MediaBox [0 0 50 50]
+/Contents 8 0 R
+>>
+endobj
+8 0 obj
+<< /Length 35 >>
+stream
+BT /F1 12 Tf 10 20 Td (Hello) Tj ET
+endstream
+endobj
+xref
+1 1
+0000000450 00000 n@
+5 4
+0000000450 00000 n@
+0000000499 00000 n@
+0000000563 00000 n@
+0000000648 00000 n@
+trailer
+<< /Size 9
+/Root 5 0 R
+/Prev 287
+>>
+startxref
+733
+%%EOF)";
+  content = absl::StrReplaceAll(content, {{"@", " "}});
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(PdfReader::Create(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Collision detected in cross-reference table "
+                                 "for in-use entries")));
+}
+
+TEST(PdfReaderTest, CyclicStreamLengthFailure) {
+  std::string content = R"(%PDF-1.7
+%%%%%
+1 0 obj
+<< /Length 1 0 R >>
+stream
+abc
+endstream
+endobj
+xref
+0 2
+0000000000 65535 f@
+0000000015 00000 n@
+trailer
+<< /Size 2 >>
+startxref
+71
+%%EOF)";
+  content = absl::StrReplaceAll(content, {{"@", " "}});
+  riegeli::StringReader<> input(content);
+  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+  EXPECT_THAT(reader->GetObject(1, 0),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Circular reference detected")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/png/BUILD b/formats/png/BUILD
new file mode 100644
index 0000000..493902a
--- /dev/null
+++ b/formats/png/BUILD
@@ -0,0 +1,177 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "reader",
+    srcs = ["reader.cc"],
+    hdrs = ["reader.h"],
+    deps = [
+        ":constants",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "reader_test",
+    srcs = ["reader_test.cc"],
+    deps = [
+        ":reader",
+        ":test_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "test_utils",
+    testonly = True,
+    srcs = ["test_utils.cc"],
+    hdrs = ["test_utils.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "crc",
+    srcs = ["crc.cc"],
+    hdrs = ["crc.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "crc_test",
+    srcs = ["crc_test.cc"],
+    deps = [
+        ":crc",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":constants",
+        ":crc",
+        ":reader",
+        "//constants:labels",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    deps = [
+        ":extractor",
+        ":test_utils",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor_result",
+        "//testing:jumbf_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        ":constants",
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        ":constants",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/png/assessor.cc b/formats/png/assessor.cc
new file mode 100644
index 0000000..0373ffa
--- /dev/null
+++ b/formats/png/assessor.cc
@@ -0,0 +1,43 @@
+// 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 "formats/png/assessor.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/png/constants.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+absl::StatusOr<bool> PngAssessor::IsSupported(riegeli::Reader& input) const {
+  int64_t starting_position = input.pos();
+
+  std::string header;
+  if (!input.Read(kPngHeader.size(), header)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; header"));
+  }
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; seek"));
+  }
+
+  return header == kPngHeader;
+}
+
+}  // namespace credentio
diff --git a/formats/png/assessor.h b/formats/png/assessor.h
new file mode 100644
index 0000000..d65ab13
--- /dev/null
+++ b/formats/png/assessor.h
@@ -0,0 +1,34 @@
+// 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_FORMATS_PNG_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Assessor for the PNG image format.
+class PngAssessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_ASSESSOR_H_
diff --git a/formats/png/assessor_test.cc b/formats/png/assessor_test.cc
new file mode 100644
index 0000000..4f30e38
--- /dev/null
+++ b/formats/png/assessor_test.cc
@@ -0,0 +1,70 @@
+// 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 "formats/png/assessor.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "formats/png/constants.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(IsSupportedTest, FailureTooShort) {
+  std::string image = "test";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(
+      PngAssessor().IsSupported(input),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof")));
+}
+
+TEST(IsSupportedTest, FailureIncorrectHeader) {
+  std::string image = "this_is_not_a_png";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(PngAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, Succeeds) {
+  std::string image = absl::StrCat(kPngHeader, "this_is_a_png");
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(PngAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, SucceedsAtOffset2) {
+  std::string image = absl::StrCat("ab", kPngHeader, "this_is_a_png");
+  riegeli::StringReader<> input(image);
+
+  // Invalid at 0
+  EXPECT_THAT(PngAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(PngAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/png/constants.h b/formats/png/constants.h
new file mode 100644
index 0000000..9f0ffa9
--- /dev/null
+++ b/formats/png/constants.h
@@ -0,0 +1,43 @@
+// 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_FORMATS_PNG_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_CONSTANTS_H_
+
+#include <cstdint>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// PNG format, see https://www.w3.org/TR/2003/REC-PNG-20031110/ Section 5.2
+constexpr absl::string_view kPngHeader = "\x89PNG\x0d\x0a\x1a\x0a";
+
+constexpr absl::string_view kPngChunkTypeHeader = "PNGh";
+constexpr absl::string_view kPngChunkTypeEnd = "IEND";
+constexpr absl::string_view kPngChunkTypeC2pa = "caBX";
+constexpr absl::string_view kPngChunkTypeIdat = "IDAT";
+constexpr absl::string_view kPngChunkTypeExif = "eXIf";
+constexpr uint32_t kPngChunkTypeExifNum = 'eXIf';
+
+const int64_t kPngChunkLengthSize = sizeof(uint32_t);
+const int64_t kPngCrcSize = sizeof(uint32_t);
+
+const int64_t kPngMinimumAssetSize =
+    kPngHeader.size() + kPngChunkTypeEnd.size() + kPngCrcSize;
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_CONSTANTS_H_
diff --git a/formats/png/crc.cc b/formats/png/crc.cc
new file mode 100644
index 0000000..fb504fd
--- /dev/null
+++ b/formats/png/crc.cc
@@ -0,0 +1,62 @@
+// 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 "formats/png/crc.h"
+
+#include <array>
+#include <cstdint>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+namespace {
+
+// Precomputed CRC-32 table (IEEE 802.3 polynomial: 0xedb88320)
+constexpr std::array<uint32_t, 256> MakeCrcTable() {
+  std::array<uint32_t, 256> table = {};
+  for (uint32_t i = 0; i < 256; ++i) {
+    uint32_t c = i;
+    for (int j = 0; j < 8; ++j) {
+      if (c & 1) {
+        c = 0xedb88320L ^ (c >> 1);
+      } else {
+        c = c >> 1;
+      }
+    }
+    table[i] = c;
+  }
+  return table;
+}
+
+static constexpr std::array<uint32_t, 256> kCrcTable = MakeCrcTable();
+
+uint32_t UpdateCrc(uint32_t crc, absl::string_view data) {
+  uint32_t c = crc;
+  for (char byte : data) {
+    c = kCrcTable[(c ^ static_cast<uint8_t>(byte)) & 0xff] ^ (c >> 8);
+  }
+  return c;
+}
+
+}  // namespace
+
+uint32_t PngChunkCrc(absl::string_view type, absl::string_view data) {
+  uint32_t crc = 0xffffffffL;
+  crc = UpdateCrc(crc, type);
+  crc = UpdateCrc(crc, data);
+  return crc ^ 0xffffffffL;
+}
+
+}  // namespace credentio
diff --git a/formats/png/crc.h b/formats/png/crc.h
new file mode 100644
index 0000000..956525b
--- /dev/null
+++ b/formats/png/crc.h
@@ -0,0 +1,31 @@
+// 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_FORMATS_PNG_CRC_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_CRC_H_
+
+#include <cstdint>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Returns the CRC of a PNG chunk as described in
+// https://www.w3.org/TR/png/#5CRC-algorithm.
+uint32_t PngChunkCrc(absl::string_view type, absl::string_view data);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_CRC_H_
diff --git a/formats/png/crc_test.cc b/formats/png/crc_test.cc
new file mode 100644
index 0000000..f0ff977
--- /dev/null
+++ b/formats/png/crc_test.cc
@@ -0,0 +1,35 @@
+// 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 "formats/png/crc.h"
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::testing::Eq;
+
+TEST(PngChunkCrcTest, ComputesCrcCorrectly) {
+  EXPECT_THAT(PngChunkCrc("abcd", "data1"), Eq(0x870ce600));
+  EXPECT_THAT(PngChunkCrc("dcba", "data1"), Eq(0xf78b39d6));
+  EXPECT_THAT(PngChunkCrc("dcba", "data2"), Eq(0x6e82686c));
+  EXPECT_THAT(PngChunkCrc("IDAT", "more data"), Eq(0x6b6f3927));
+  EXPECT_THAT(PngChunkCrc("IEND", ""), Eq(0xae426082));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/png/extractor.cc b/formats/png/extractor.cc
new file mode 100644
index 0000000..1e4d8a4
--- /dev/null
+++ b/formats/png/extractor.cc
@@ -0,0 +1,253 @@
+// 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 "formats/png/extractor.h"
+
+#include <sys/types.h>
+
+#include <algorithm>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "constants/labels.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/png/constants.h"
+#include "formats/png/crc.h"
+#include "formats/png/reader.h"
+#include "jumbf/utils.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+
+namespace {
+constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;  // 10 MiB
+
+absl::StatusOr<std::string> ValidateCrcAndReturnPayload(riegeli::Reader& input,
+                                                        PngChunk chunk,
+                                                        int64_t end_offset) {
+  if (chunk.data_length > kMaxPayloadSize) {
+    return absl::InvalidArgumentError(
+        absl::Substitute("PNG C2PA chunk is too large to extract ($0 > $1)",
+                         chunk.data_length, kMaxPayloadSize));
+  }
+
+  std::string payload;
+  if (!input.Read(chunk.data_length, payload)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read payload"));
+  }
+
+  uint32_t extracted_crc;
+  if (!riegeli::ReadBigEndian<uint32_t>(input, extracted_crc)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failed to read CRC"));
+  }
+
+  uint32_t computed_crc = PngChunkCrc(chunk.type, payload);
+  if (extracted_crc != computed_crc) {
+    return absl::InvalidArgumentError(
+        absl::Substitute("chunk CRC 0x$0 does not match computed CRC 0x$1",
+                         absl::Hex(extracted_crc), absl::Hex(computed_crc)));
+  }
+
+  return std::move(payload);
+}
+
+absl::Status ValidateAssetSize(riegeli::Reader& input, int64_t end_offset) {
+  int64_t asset_size =
+      (end_offset < 0 ? input.Size().value_or(input.pos()) : end_offset) -
+      input.pos();
+  if (asset_size < kPngMinimumAssetSize) {
+    // The asset is too small, we can't possibly have a manifest store.
+    return absl::NotFoundError("No manifest store found");
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ValidateWithinAssetWindow(const PngChunk& chunk,
+                                       int64_t end_offset) {
+  // If we are iterating over the entire asset, return early.
+  if (end_offset < 0) {
+    return absl::OkStatus();
+  }
+
+  if (chunk.type == "c2pa.after") {
+    return chunk.offset >= end_offset ? absl::AbortedError("asset-window-end")
+                                      : absl::OkStatus();
+  }
+
+  if (chunk.offset >= end_offset) {
+    // Chunk starts at or past the end of the asset window, early return.
+    return absl::AbortedError("asset-window-end");
+  }
+
+  if (chunk.offset + chunk.length > end_offset) {
+    // Chunk starts within the asset window, but extends beyond, return error.
+    return absl::InvalidArgumentError(
+        "PNG chunk extends beyond the end of the file");
+  }
+
+  // Chunk starts within the asset window and does not extend beyond it.
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> PngExtractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  std::optional<std::string> result = std::nullopt;
+  absl::Status iteration_status = IterateOverPngChunks(
+      input, [&result, &input](const PngChunk& chunk) -> absl::Status {
+        if (chunk.type != kPngChunkTypeC2pa) {
+          return absl::OkStatus();
+        }
+
+        if (result.has_value()) {
+          return absl::NotFoundError("Multiple manifest stores found");
+        }
+
+        ABSL_ASSIGN_OR_RETURN(
+            result, ValidateCrcAndReturnPayload(input, chunk,
+                                                input.Size().value_or(0)));
+        return absl::OkStatus();
+      });
+  ABSL_RETURN_IF_ERROR(iteration_status);
+  if (result.has_value()) {
+    return *result;
+  }
+  return absl::NotFoundError("No manifest store found");
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+PngExtractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                           ExtractOptions options) const {
+  std::optional<ByteRange> result = std::nullopt;
+
+  if (!ValidateAssetSize(input, options.end_offset).ok()) {
+    if (options.requires_c2pa) {
+      return absl::NotFoundError("No manifest store found");
+    }
+    return result;
+  }
+
+  absl::Status iteration_status = IterateOverPngChunks(
+      input,
+      [&result, &options, &input](const PngChunk& chunk) -> absl::Status {
+        ABSL_RETURN_IF_ERROR(
+            ValidateWithinAssetWindow(chunk, options.end_offset));
+
+        if (chunk.type != kPngChunkTypeC2pa) {
+          return absl::OkStatus();
+        }
+        if (result.has_value()) {
+          return absl::NotFoundError("Multiple manifest stores found");
+        }
+
+        // Ensure the C2PA chunk is valid before using it.
+        ABSL_RETURN_IF_ERROR(
+            ValidateCrcAndReturnPayload(input, chunk, input.Size().value_or(0))
+                .status());
+
+        result = {.offset = chunk.offset, .length = chunk.length};
+
+        return absl::OkStatus();
+      });
+  if (iteration_status.code() != absl::StatusCode::kAborted) {
+    // Ignore the aborted error, it means we've reached the end of the asset
+    // window in a valid manner.
+    ABSL_RETURN_IF_ERROR(iteration_status);
+  }
+
+  if (options.requires_c2pa && !result.has_value()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return result;
+}
+
+absl::StatusOr<std::vector<AssetBox>> PngExtractor::ExtractBoxes(
+    riegeli::Reader& input, ExtractOptions options) const {
+  std::vector<AssetBox> result;
+
+  if (!ValidateAssetSize(input, options.end_offset).ok()) {
+    if (options.requires_c2pa) {
+      return absl::NotFoundError("No manifest store found");
+    }
+    return result;
+  }
+
+  int64_t c2pa_chunks_found = 0;
+  absl::Status iteration_status = IterateOverPngChunks(
+      input,
+      [&result, &options, &c2pa_chunks_found,
+       &input](const PngChunk& chunk) -> absl::Status {
+        ABSL_RETURN_IF_ERROR(
+            ValidateWithinAssetWindow(chunk, options.end_offset));
+
+        std::string identifier = chunk.type;
+        if (chunk.type == kPngChunkTypeC2pa) {
+          c2pa_chunks_found++;
+          identifier = "C2PA";
+
+          if (c2pa_chunks_found > 1) {
+            return absl::NotFoundError("Multiple manifest stores found");
+          }
+          // Ensure the C2PA chunk is valid.
+          if (auto payload_or =
+                  ValidateCrcAndReturnPayload(input, chunk, options.end_offset);
+              !payload_or.ok()) {
+            return payload_or.status();
+          }
+        }
+
+        uint64_t length = chunk.length;
+        if (identifier == "c2pa.after" && options.end_offset >= 0) {
+          length = std::min(
+              length, static_cast<uint64_t>(options.end_offset - chunk.offset));
+        }
+        result.push_back(AssetBox{
+            .identifier = std::move(identifier),
+            .byte_range = {.offset = chunk.offset, .length = length},
+        });
+        return absl::OkStatus();
+      });
+  if (iteration_status.code() != absl::StatusCode::kAborted) {
+    // Ignore the aborted error, it means we've reached the end of the asset
+    // window in a valid manner.
+    ABSL_RETURN_IF_ERROR(iteration_status);
+  }
+  if (options.requires_c2pa && c2pa_chunks_found == 0) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return result;
+}
+
+bool PngExtractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+
+}  // namespace credentio
diff --git a/formats/png/extractor.h b/formats/png/extractor.h
new file mode 100644
index 0000000..edf652c
--- /dev/null
+++ b/formats/png/extractor.h
@@ -0,0 +1,53 @@
+// 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_FORMATS_PNG_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Implements the C2PA Manifest Store extractor for PNG files as described in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_png
+class PngExtractor : public FormatExtractor {
+ public:
+  PngExtractor() = default;
+  ~PngExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_EXTRACTOR_H_
diff --git a/formats/png/extractor_test.cc b/formats/png/extractor_test.cc
new file mode 100644
index 0000000..faff925
--- /dev/null
+++ b/formats/png/extractor_test.cc
@@ -0,0 +1,419 @@
+// 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 "formats/png/extractor.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor_result.h"
+#include "formats/png/test_utils.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/jumbf_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+
+constexpr int64_t kMaxChunkBytes = 2 * 1048576;  // 2MiB
+
+std::vector<AssetBox> CreateBoxes(
+    std::vector<std::pair<std::string, uint32_t>> expected) {
+  uint64_t offset = 0;
+  std::vector<AssetBox> boxes;
+  for (const auto& name_and_size : expected) {
+    boxes.push_back(AssetBox{
+        .identifier = name_and_size.first,
+        .byte_range = {.offset = offset, .length = name_and_size.second},
+    });
+    offset += name_and_size.second;
+  }
+  return boxes;
+}
+
+struct TestCase {
+  std::string name;
+  std::string contents;
+  absl::StatusOr<ExtractorResult> result;
+  uint32_t max_chunk_bytes = kMaxChunkBytes;
+};
+
+class ExtractorTest : public testing::TestWithParam<TestCase> {};
+
+INSTANTIATE_TEST_SUITE_P(
+    ExtractorTests, ExtractorTest,
+    testing::ValuesIn({
+        TestCase{
+            .name = "EmptyContent",
+            .contents = "",
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "NoPngMarker",
+            .contents = "this_is_clearly_not_a_png",
+            .result = absl::InvalidArgumentError(
+                "input does not start with PNG marker"),
+        },
+        TestCase{
+            .name = "OnlyPngMarker",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png =
+                      CreatePng({}, /*add_header=*/true, /*add_end=*/false);
+                  CHECK_OK(png);
+                  return *png;
+                }(),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "EmptyPng",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png = CreatePng({});
+                  CHECK_OK(png);
+                  return *png;
+                }(),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "NonC2paInvalidCrcIsIgnored",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png =
+                      CreatePng({{.type = 'IEND', .payload = ""}},
+                                /*add_header=*/true, /*add_end=*/false);
+                  CHECK_OK(png);
+                  return *png;
+                }(),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "C2paFound",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png = CreatePng({
+                      {.type = 'IDAT', .payload = "ignored IDAT chunk"},
+                      {.type = 'caBX',
+                       .payload = "A C2PA manifest store",
+                       .crc = 0xdb50c4b5},
+                  });
+                  CHECK_OK(png);
+                  return *png;
+                }(),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "A C2PA manifest store",
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = {.offset = 38,
+                                                        .length = 33},
+                            .boxes = CreateBoxes({{"PNGh", /*size=*/8},
+                                                  {"IDAT", /*size=*/30},
+                                                  {"C2PA", /*size=*/33},
+                                                  {"IEND", /*size=*/12}}),
+                        },
+                },
+        },
+        TestCase{
+            .name = "TruncatedChunk",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> data = CreatePng(
+                      {{.type = 'IDAT', .payload = "ignored IDAT chunk"}},
+                      /*add_header=*/true, /*add_end=*/false);
+                  CHECK_OK(data);
+                  data->resize(data->size() - 1);
+                  return *data;
+                }(),
+            .result = absl::InvalidArgumentError(
+                "PNG chunk extends beyond the end of the file"),
+        },
+        TestCase{
+            .name = "C2paInvalidCrc",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png = CreatePng(
+                      {{.type = 'caBX', .payload = "", .crc = 0x11111111}});
+                  CHECK_OK(png);
+                  return *png;
+                }(),
+            .result = absl::InvalidArgumentError("does not match computed CRC"),
+        },
+        TestCase{
+            .name = "C2paChunkTooLarge",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png = CreatePng({
+                      {.type = 'caBX',
+                       .payload = std::string(10 * 1024 * 1024 + 1, 'a')},
+                  });
+                  CHECK_OK(png);
+                  return *png;
+                }(),
+            .result = absl::InvalidArgumentError(
+                "PNG C2PA chunk is too large to extract"),
+        },
+        TestCase{
+            .name = "MultipleC2paChunks",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png = CreatePng({
+                      {.type = 'caBX', .payload = "", .crc = 0xe63dd2a7},
+                      {.type = 'caBX', .payload = "", .crc = 0xe63dd2a7},
+                  });
+                  CHECK_OK(png);
+                  return *png;
+                }(),
+            .result = absl::NotFoundError("Multiple manifest stores found"),
+        },
+        TestCase{
+            .name = "LargeIDATChunk",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png = CreatePng({
+                      {.type = 'IDAT',
+                       .payload = "this_is_a_large_idat_chunk",
+                       .crc = 2230102072},
+                  });
+                  CHECK_OK(png);
+                  return *png;
+                }(),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "ContainsAdditionalData",
+            .contents =
+                []() {
+                  absl::StatusOr<std::string> png = CreatePng({
+                      {.type = 'IDAT', .payload = "ignored IDAT chunk"},
+                      {.type = 'caBX',
+                       .payload = "A C2PA manifest store",
+                       .crc = 0xdb50c4b5},
+                  });
+                  CHECK_OK(png);
+                  return *png + *png;
+                }(),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "A C2PA manifest store",
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = {.offset = 38,
+                                                        .length = 33},
+                            .boxes = CreateBoxes({{"PNGh", /*size=*/8},
+                                                  {"IDAT", /*size=*/30},
+                                                  {"C2PA", /*size=*/33},
+                                                  {"IEND", /*size=*/12},
+                                                  {"c2pa.after", /*size=*/83}}),
+                        },
+                },
+        },
+    }),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+TEST_P(ExtractorTest, Extract) {
+  const TestCase& test_case = GetParam();
+  riegeli::StringReader<> input(test_case.contents);
+  absl::StatusOr<std::string> result =
+      PngExtractor().ExtractManifestStore(input);
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(expected_result->manifest_store));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) {
+  const TestCase& test_case = GetParam();
+  std::string contents = "padding" + test_case.contents;
+  riegeli::StringReader<> input(contents);
+
+  ASSERT_TRUE(input.Seek(7));
+  absl::StatusOr<std::string> result =
+      PngExtractor().ExtractManifestStore(input);
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(expected_result->manifest_store));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffixPadded) {
+  const TestCase& test_case = GetParam();
+  std::string contents = test_case.contents + "padding";
+  riegeli::StringReader<> input(contents);
+
+  absl::StatusOr<std::optional<ByteRange>> result =
+      PngExtractor().ExtractManifestStoreLocation(
+          input, {.end_offset = static_cast<int64_t>(contents.size()) - 7});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    EXPECT_THAT(
+        result,
+        IsOkAndHolds(expected_result->asset_byte_info.manifest_store_location));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxesWithSuffixPadded) {
+  const TestCase& test_case = GetParam();
+  std::string contents = test_case.contents + "padding";
+  riegeli::StringReader<> input(contents);
+
+  absl::StatusOr<std::vector<AssetBox>> result = PngExtractor().ExtractBoxes(
+      input, {.end_offset = static_cast<int64_t>(contents.size()) - 7});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(expected_result->asset_byte_info.boxes));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocationRequiresC2pa) {
+  const TestCase& test_case = GetParam();
+  riegeli::StringReader<> input(test_case.contents);
+
+  absl::StatusOr<std::optional<ByteRange>> result =
+      PngExtractor().ExtractManifestStoreLocation(input, {});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    EXPECT_THAT(
+        result,
+        IsOkAndHolds(expected_result->asset_byte_info.manifest_store_location));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocationDoesNotRequireC2pa) {
+  const TestCase& test_case = GetParam();
+  riegeli::StringReader<> input(test_case.contents);
+
+  absl::StatusOr<std::optional<ByteRange>> result =
+      PngExtractor().ExtractManifestStoreLocation(input,
+                                                  {.requires_c2pa = false});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    EXPECT_THAT(
+        result,
+        IsOkAndHolds(expected_result->asset_byte_info.manifest_store_location));
+  } else if (expected_result.status().code() == absl::StatusCode::kNotFound &&
+             expected_result.status().message() !=
+                 "Multiple manifest stores found") {
+    EXPECT_THAT(result, IsOkAndHolds(std::nullopt));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxes) {
+  const TestCase& test_case = GetParam();
+  riegeli::StringReader<> input(test_case.contents);
+
+  absl::StatusOr<std::vector<AssetBox>> result =
+      PngExtractor().ExtractBoxes(input, {});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(expected_result->asset_byte_info.boxes));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST(PngExtractorTest, IsManifestStore) {
+  EXPECT_TRUE(credentio::PngExtractor().MightBeC2paManifestStore(
+      credentio::CreateStartOfManifestStorePayload()));
+}
+
+TEST(PngExtractorTest, ExtractManifestStoreLocationWithEndOffsetBeforeC2pa) {
+  absl::StatusOr<std::string> png = CreatePng({
+      {.type = 'IDAT', .payload = "ignored IDAT chunk"},
+      {.type = 'caBX', .payload = "A C2PA manifest store", .crc = 0xdb50c4b5},
+  });
+  ASSERT_THAT(png, ::absl_testing::IsOk());
+  riegeli::StringReader<> input(*png);
+
+  // Set end_offset to 38 (right before caBX).
+  // This should cause caBX to be considered past the asset window,
+  // triggering the abort early return in ValidateWithinAssetWindow.
+  auto result = PngExtractor().ExtractManifestStoreLocation(
+      input, {.requires_c2pa = false, .end_offset = 38});
+  EXPECT_THAT(result, IsOkAndHolds(std::nullopt));
+}
+
+TEST(PngExtractorTest, ExtractBoxesDoesNotRequireC2paAndAssetTooSmall) {
+  riegeli::StringReader<> input("");
+  auto result = PngExtractor().ExtractBoxes(input, {.requires_c2pa = false});
+  EXPECT_THAT(result, IsOkAndHolds(IsEmpty()));
+}
+
+TEST(PngExtractorTest,
+     ExtractManifestStoreLocationAssetJustTooSmallDoesNotRequireC2pa) {
+  // kPngMinimumAssetSize is 16. We use 15 bytes.
+  std::string contents = std::string(
+      "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x00\x00\x00\x00", 15);
+  riegeli::StringReader<> input(contents);
+  auto result = PngExtractor().ExtractManifestStoreLocation(
+      input, {.requires_c2pa = false});
+  EXPECT_THAT(result, IsOkAndHolds(std::nullopt));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/png/format.cc b/formats/png/format.cc
new file mode 100644
index 0000000..477ace1
--- /dev/null
+++ b/formats/png/format.cc
@@ -0,0 +1,35 @@
+// 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 "formats/png/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+#include "formats/png/assessor.h"
+#include "formats/png/extractor.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreatePngFormat() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<PngAssessor>(),
+      .extractor = std::make_unique<PngExtractor>(),
+      .mime_types = {"image/png"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/png/format.h b/formats/png/format.h
new file mode 100644
index 0000000..b5e0235
--- /dev/null
+++ b/formats/png/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_PNG_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreatePngFormat();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_FORMAT_H_
diff --git a/formats/png/reader.cc b/formats/png/reader.cc
new file mode 100644
index 0000000..0e3a3b9
--- /dev/null
+++ b/formats/png/reader.cc
@@ -0,0 +1,146 @@
+// 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 "formats/png/reader.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/ascii.h"
+#include "absl/strings/str_cat.h"
+#include "formats/png/constants.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+
+namespace {
+
+absl::StatusOr<PngChunk> ReadHeader(riegeli::Reader& reader) {
+  uint64_t start_offset = reader.pos();
+  std::string header;
+  if (!reader.Read(kPngHeader.size(), header)) {
+    return reader.StatusOrAnnotate(
+        absl::InvalidArgumentError("Failed to read header"));
+  }
+  if (header != kPngHeader) {
+    return absl::InvalidArgumentError("input does not start with PNG marker");
+  }
+  return PngChunk{
+      .offset = start_offset,
+      .length = kPngHeader.size(),
+      .type = kPngChunkTypeHeader.data(),
+      .data_length = static_cast<uint32_t>(kPngHeader.size()),
+  };
+}
+
+bool ChunkGoesPastEndOffset(uint64_t chunk_start, uint64_t chunk_length,
+                            uint64_t end_offset) {
+  if (end_offset < chunk_length) {
+    // Chunk is larger than the file size
+    return true;
+  }
+  if (chunk_start > (end_offset - chunk_length)) {
+    // Chunk starts too far into the file to read the entire chunk.
+    return true;
+  }
+  return false;
+}
+
+absl::StatusOr<PngChunk> ReadChunk(riegeli::Reader& reader,
+                                   int64_t end_offset) {
+  PngChunk chunk{.offset = reader.pos()};
+  if (!riegeli::ReadBigEndian<uint32_t>(reader, chunk.data_length)) {
+    return reader.StatusOrAnnotate(
+        absl::DataLossError("Failed to read chunk data length"));
+  }
+  chunk.length = sizeof(uint32_t)                            // Chunk length
+                 + sizeof(uint32_t)                          // Chunk type
+                 + static_cast<uint64_t>(chunk.data_length)  // Chunk data
+                 + sizeof(uint32_t);                         // CRC
+  if (ChunkGoesPastEndOffset(chunk.offset, chunk.length, end_offset)) {
+    return absl::InvalidArgumentError(
+        "PNG chunk extends beyond the end of the file");
+  }
+  if (!reader.Read(4, chunk.type)) {
+    return reader.StatusOrAnnotate(
+        absl::DataLossError("Failed to read chunk type"));
+  }
+  for (char c : chunk.type) {
+    if (!absl::ascii_isalpha(c)) {
+      return absl::InvalidArgumentError(
+          absl::StrCat("Invalid PNG chunk type name: ", chunk.type));
+    }
+  }
+  return std::move(chunk);
+}
+
+}  // namespace
+
+absl::Status IterateOverPngChunks(riegeli::Reader& reader,
+                                  PngChunkProcessor processor) {
+  if (!reader.SupportsSize() || !reader.Size().has_value()) {
+    return absl::InvalidArgumentError(
+        "PNG reader does not support size, cannot iterate");
+  }
+  uint64_t end_offset = *reader.Size();
+  if (kPngMinimumAssetSize > end_offset - reader.pos()) {
+    // Asset size is smaller than the minimum size needed, just return.
+    return absl::OkStatus();
+  }
+
+  ABSL_ASSIGN_OR_RETURN(PngChunk header, ReadHeader(reader));
+
+  ABSL_RETURN_IF_ERROR(processor(header));
+  uint64_t last_chunk_offset = header.offset;
+  while (reader.pos() < end_offset) {
+    ABSL_ASSIGN_OR_RETURN(PngChunk chunk, ReadChunk(reader, end_offset));
+
+    if (chunk.offset <= last_chunk_offset) {
+      return reader.StatusOrAnnotate(
+          absl::InvalidArgumentError("PNG chunk offset did not advance"));
+    }
+    last_chunk_offset = chunk.offset;
+
+    ABSL_RETURN_IF_ERROR(processor(chunk));
+    if (!reader.Seek(chunk.offset + chunk.length)) {
+      return reader.StatusOrAnnotate(
+          absl::InvalidArgumentError("Failed to seek to next chunk"));
+    }
+
+    if (chunk.type == kPngChunkTypeEnd) {
+      break;
+    }
+  }
+
+  if (reader.pos() < end_offset) {
+    PngChunk chunk{
+        .offset = reader.pos(),
+        .length = end_offset - reader.pos(),
+        .type = "c2pa.after",
+        .data_length = 0,
+    };
+    ABSL_RETURN_IF_ERROR(processor(chunk));
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/formats/png/reader.h b/formats/png/reader.h
new file mode 100644
index 0000000..7d4494a
--- /dev/null
+++ b/formats/png/reader.h
@@ -0,0 +1,53 @@
+// 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_FORMATS_PNG_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_READER_H_
+
+#include <cstdint>
+#include <string>
+
+#include "absl/functional/function_ref.h"
+#include "absl/status/status.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+struct PngChunk {
+  uint64_t offset;
+  uint64_t length;
+
+  std::string type;
+  uint32_t data_length;
+};
+
+inline bool operator==(const PngChunk& lhs, const PngChunk& rhs) {
+  return lhs.offset == rhs.offset && lhs.length == rhs.length &&
+         lhs.type == rhs.type && lhs.data_length == rhs.data_length;
+}
+
+// The function should return an OK status to continue iteration, or a non-OK
+// status to abort iteration.
+using PngChunkProcessor = absl::FunctionRef<absl::Status(const PngChunk&)>;
+
+// Iterates over all PNG Chunks in the given reader, calling the given function
+// for each box. The method will seek to position 0 in the reader before
+// iterating.
+absl::Status IterateOverPngChunks(riegeli::Reader& reader,
+                                  PngChunkProcessor processor);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_READER_H_
diff --git a/formats/png/reader_test.cc b/formats/png/reader_test.cc
new file mode 100644
index 0000000..9a1dde2
--- /dev/null
+++ b/formats/png/reader_test.cc
@@ -0,0 +1,122 @@
+// 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 "formats/png/reader.h"
+
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "formats/png/test_utils.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+
+absl::StatusOr<std::vector<PngChunk>> GetChunks(riegeli::Reader& reader) {
+  std::vector<PngChunk> chunks;
+  ABSL_RETURN_IF_ERROR(IterateOverPngChunks(reader, [&](const PngChunk& chunk) {
+    chunks.push_back(std::move(chunk));
+    return absl::OkStatus();
+  }));
+  return chunks;
+}
+
+absl::StatusOr<std::vector<PngChunk>> GetChunksFromContents(
+    absl::string_view contents) {
+  riegeli::StringReader reader(contents);
+  return GetChunks(reader);
+}
+
+using PngReaderTest = testing::Test;
+
+TEST_F(PngReaderTest, NoData) {
+  EXPECT_THAT(GetChunksFromContents(""), IsOkAndHolds(IsEmpty()));
+}
+
+TEST_F(PngReaderTest, InvalidHeader) {
+  std::string content = "abcdefghijklmnopqrstuvwxyz";
+
+  EXPECT_THAT(GetChunksFromContents(content),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("input does not start with PNG marker")));
+}
+
+TEST_F(PngReaderTest, ClosedReader) {
+  std::string content = "abcdefghijklmnopqrstuvwxyz";
+
+  riegeli::StringReader reader(content);
+  EXPECT_TRUE(reader.Close());
+
+  EXPECT_THAT(
+      GetChunks(reader),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("PNG reader does not support size, cannot iterate")));
+}
+
+TEST_F(PngReaderTest, ChunkExtendsBeyondEndOfFile) {
+  auto content_or = CreatePng({{.type = 'IDAT', .payload = "a"}});
+  if (!content_or.ok()) {
+    FAIL() << "Failed to create PNG: " << content_or.status();
+  }
+  std::string content = *content_or;
+  content[11] = '\xff';  // Marks the IDAT chunk as 255 bytes long.
+
+  EXPECT_THAT(
+      GetChunksFromContents(content),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("PNG chunk extends beyond the end of the file")));
+}
+
+TEST_F(PngReaderTest, ChunkLengthOverflowPrevention) {
+  auto content_or = CreatePng({{.type = 'IDAT', .payload = "a"}});
+  ASSERT_TRUE(content_or.ok());
+  std::string content = *content_or;
+  content[8] = '\xff';
+  content[9] = '\xff';
+  content[10] = '\xff';
+  content[11] = '\xf4';  // data_length = 0xfffffff4 (-12 in 32-bit)
+
+  EXPECT_THAT(
+      GetChunksFromContents(content),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("PNG chunk extends beyond the end of the file")));
+}
+
+TEST_F(PngReaderTest, InvalidChunkType) {
+  auto content_or = CreatePng({{.type = 'C2PA', .payload = "a"}});
+  ASSERT_TRUE(content_or.ok());
+
+  EXPECT_THAT(GetChunksFromContents(*content_or),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Invalid PNG chunk type name: C2PA")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/png/test_utils.cc b/formats/png/test_utils.cc
new file mode 100644
index 0000000..f652bd9
--- /dev/null
+++ b/formats/png/test_utils.cc
@@ -0,0 +1,85 @@
+// 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 "formats/png/test_utils.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/types/span.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+
+absl::Status WritePngHeader(riegeli::Writer& buffer) {
+  const std::string header = "\x89PNG\x0d\x0a\x1a\x0a";
+  if (!buffer.Write(header)) {
+    return buffer.StatusOrAnnotate(
+        absl::InternalError("Failed to write PNG header"));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WritePngChunk(riegeli::Writer& buffer, const Chunk& chunk) {
+  if (!riegeli::WriteBigEndian<uint32_t>(chunk.payload.size(), buffer)) {
+    return buffer.StatusOrAnnotate(
+        absl::InternalError("Failed to write chunk size"));
+  }
+  if (!riegeli::WriteBigEndian<uint32_t>(chunk.type, buffer)) {
+    return buffer.StatusOrAnnotate(
+        absl::InternalError("Failed to write chunk type"));
+  }
+  if (!chunk.payload.empty()) {
+    if (!buffer.Write(chunk.payload)) {
+      return buffer.StatusOrAnnotate(
+          absl::InternalError("Failed to write chunk payload"));
+    }
+  }
+  if (!riegeli::WriteBigEndian<uint32_t>(chunk.crc.value_or(0), buffer)) {
+    return buffer.StatusOrAnnotate(
+        absl::InternalError("Failed to write chunk CRC"));
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WritePngEnd(riegeli::Writer& buffer) {
+  return WritePngChunk(buffer, Chunk{.type = 'IEND', .payload = ""});
+}
+
+absl::StatusOr<std::string> CreatePng(absl::Span<const Chunk> chunks,
+                                      bool add_header, bool add_end) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+  if (add_header) {
+    ABSL_RETURN_IF_ERROR(WritePngHeader(writer));
+  }
+  for (const Chunk& chunk : chunks) {
+    ABSL_RETURN_IF_ERROR(WritePngChunk(writer, chunk));
+  }
+  if (add_end) {
+    ABSL_RETURN_IF_ERROR(WritePngEnd(writer));
+  }
+  if (!writer.Close()) {
+    return writer.status();
+  }
+  return buffer;
+}
+
+}  // namespace credentio
diff --git a/formats/png/test_utils.h b/formats/png/test_utils.h
new file mode 100644
index 0000000..302c0e5
--- /dev/null
+++ b/formats/png/test_utils.h
@@ -0,0 +1,51 @@
+// 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_FORMATS_PNG_TEST_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_TEST_UTILS_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/types/span.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// A PNG chunk.
+struct Chunk {
+  // Chunk type (e.g., 'IDAT' or 'caBX').
+  uint32_t type;
+  std::string payload;
+  // If set, the CRC will be set to this value.
+  std::optional<uint32_t> crc = std::nullopt;
+};
+
+// Creates a PNG image from the given chunks.
+absl::StatusOr<std::string> CreatePng(absl::Span<const Chunk> chunks,
+                                      bool add_header = true,
+                                      bool add_end = true);
+
+// Lower-level functions.
+absl::Status WritePngHeader(riegeli::Writer& buffer);
+absl::Status WritePngChunk(riegeli::Writer& buffer, const Chunk& chunk);
+absl::Status WritePngEnd(riegeli::Writer& buffer);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_TEST_UTILS_H_
diff --git a/formats/registry.cc b/formats/registry.cc
new file mode 100644
index 0000000..5f4ad97
--- /dev/null
+++ b/formats/registry.cc
@@ -0,0 +1,93 @@
+// 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 "formats/registry.h"
+
+#include <memory>
+#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 "formats/format.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+void FormatRegistry::Register(std::unique_ptr<Format> format) {
+  for (const auto& media_type : format->mime_types()) {
+    format_for_type_[media_type].push_back(format.get());
+  }
+  formats_.push_back(std::move(format));
+}
+
+absl::StatusOr<const Format*> FormatRegistry::GetFormat(
+    absl::string_view media_type) const {
+  auto it = format_for_type_.find(media_type);
+  if (it == format_for_type_.end()) {
+    return absl::UnimplementedError(
+        absl::StrCat("Unsupported media type: ", media_type));
+  }
+  if (it->second.empty()) {
+    return absl::UnimplementedError(
+        absl::StrCat("Unsupported media type: ", media_type));
+  }
+  if (it->second.size() == 1) {
+    return it->second[0];
+  }
+  return absl::UnimplementedError(
+      absl::StrCat("Multiple formats support media type: ", media_type));
+}
+
+absl::StatusOr<const Format*> FormatRegistry::GetFormat(
+    riegeli::Reader& asset) const {
+  for (const std::unique_ptr<Format>& format : formats_) {
+    absl::StatusOr<bool> is_supported = format->assessor()->IsSupported(asset);
+    if (is_supported.ok() && *is_supported) {
+      // Asset is supported by this format.
+      return format.get();
+    }
+    // Errors are treated as unsupported by the format.
+  }
+  return absl::UnimplementedError("No applicable Format found");
+}
+
+absl::StatusOr<const Format*> FormatRegistry::GetFormat(
+    absl::string_view media_type, riegeli::Reader& asset) const {
+  auto it = format_for_type_.find(media_type);
+  if (it == format_for_type_.end()) {
+    return absl::UnimplementedError(
+        absl::StrCat("Unsupported media type: ", media_type));
+  }
+  if (it->second.empty()) {
+    return absl::UnimplementedError(
+        absl::StrCat("Unsupported media type: ", media_type));
+  }
+  if (it->second.size() == 1) {
+    return it->second[0];
+  }
+  for (const Format* format : it->second) {
+    absl::StatusOr<bool> is_supported = format->assessor()->IsSupported(asset);
+    if (is_supported.ok() && *is_supported) {
+      // Asset is supported by this format.
+      return format;
+    }
+    // Errors are treated as unsupported by the format.
+  }
+  return absl::UnimplementedError("No applicable Format found");
+}
+
+}  // namespace credentio
diff --git a/formats/registry.h b/formats/registry.h
new file mode 100644
index 0000000..df17f47
--- /dev/null
+++ b/formats/registry.h
@@ -0,0 +1,52 @@
+// 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_FORMATS_REGISTRY_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_REGISTRY_H_
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/format.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+class FormatRegistry {
+ public:
+  // Takes ownership of the format.
+  void Register(std::unique_ptr<Format> format);
+
+  absl::StatusOr<const Format*> GetFormat(absl::string_view media_type) const;
+
+  absl::StatusOr<const Format*> GetFormat(riegeli::Reader& asset) const;
+
+  absl::StatusOr<const Format*> GetFormat(absl::string_view media_type,
+                                          riegeli::Reader& asset) const;
+
+ private:
+  // Formats.
+  std::vector<std::unique_ptr<Format>> formats_;
+  // Map from media type to format.
+  absl::flat_hash_map<std::string, std::vector<const Format*>> format_for_type_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_REGISTRY_H_
diff --git a/formats/registry_test.cc b/formats/registry_test.cc
new file mode 100644
index 0000000..16137d2
--- /dev/null
+++ b/formats/registry_test.cc
@@ -0,0 +1,237 @@
+// 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 "formats/registry.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/assessor.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor.h"
+#include "formats/format.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+class ErrorAssessor : public FormatAssessor {
+ public:
+  ErrorAssessor() = default;
+  ~ErrorAssessor() override = default;
+
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override {
+    return absl::UnimplementedError("Not implemented");
+  }
+};
+
+class PassAssessor : public FormatAssessor {
+ public:
+  PassAssessor() = default;
+  ~PassAssessor() override = default;
+
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override {
+    return true;
+  }
+};
+
+class FailAssessor : public FormatAssessor {
+ public:
+  FailAssessor() = default;
+  ~FailAssessor() override = default;
+
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override {
+    return false;
+  }
+};
+
+class FakeExtractor : public FormatExtractor {
+ public:
+  FakeExtractor() = default;
+  ~FakeExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override {
+    return absl::UnimplementedError("Not implemented");
+  }
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError("Not implemented");
+  }
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError("Not implemented");
+  }
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override {
+    return true;
+  }
+};
+
+class FormatRegistryTest : public ::testing::Test {
+ protected:
+  void SetUp() override { registry_ = std::make_unique<FormatRegistry>(); }
+
+  void RegisterErrorFormat(std::vector<std::string> mime_types) {
+    auto format = Format::Create(FormatOptions{
+        .assessor = std::make_unique<ErrorAssessor>(),
+        .extractor = std::make_unique<FakeExtractor>(),
+        .mime_types = std::move(mime_types),
+    });
+    ABSL_ASSERT_OK(format);
+    registry_->Register(*std::move(format));
+  }
+
+  void RegisterSuccessFormat(std::vector<std::string> mime_types) {
+    auto format = Format::Create(FormatOptions{
+        .assessor = std::make_unique<PassAssessor>(),
+        .extractor = std::make_unique<FakeExtractor>(),
+        .mime_types = std::move(mime_types),
+    });
+    ABSL_ASSERT_OK(format);
+    registry_->Register(*std::move(format));
+  }
+
+  void RegisterFailFormat(std::vector<std::string> mime_types) {
+    auto format = Format::Create(FormatOptions{
+        .assessor = std::make_unique<FailAssessor>(),
+        .extractor = std::make_unique<FakeExtractor>(),
+        .mime_types = std::move(mime_types),
+    });
+    ABSL_ASSERT_OK(format);
+    registry_->Register(*std::move(format));
+  }
+
+  std::unique_ptr<FormatRegistry> registry_;
+};
+
+TEST_F(FormatRegistryTest, MediaTypeNotFound) {
+  EXPECT_THAT(registry_->GetFormat("jpeg"),
+              StatusIs(absl::StatusCode::kUnimplemented,
+                       HasSubstr("Unsupported media type: jpeg")));
+}
+
+TEST_F(FormatRegistryTest, MediaTypeWithSingleFormat) {
+  RegisterSuccessFormat({"jpeg"});
+
+  EXPECT_THAT(registry_->GetFormat("jpeg"), IsOkAndHolds(testing::NotNull()));
+}
+
+TEST_F(FormatRegistryTest, MediaTypeWithMultipleFormats) {
+  RegisterSuccessFormat({"jpeg"});
+  RegisterSuccessFormat({"jpeg"});
+
+  EXPECT_THAT(registry_->GetFormat("jpeg"),
+              StatusIs(absl::StatusCode::kUnimplemented,
+                       HasSubstr("Multiple formats support media type: jpeg")));
+}
+
+TEST_F(FormatRegistryTest, AssetNotSupported) {
+  RegisterFailFormat({"jpeg"});
+
+  std::string asset = "asset";
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(registry_->GetFormat(input),
+              StatusIs(absl::StatusCode::kUnimplemented,
+                       HasSubstr("No applicable Format found")));
+}
+
+TEST_F(FormatRegistryTest, AssetSupported) {
+  RegisterSuccessFormat({"jpeg"});
+
+  std::string asset = "asset";
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(registry_->GetFormat(input), IsOkAndHolds(testing::NotNull()));
+}
+
+TEST_F(FormatRegistryTest, AssetFailsThenSupports) {
+  RegisterFailFormat({"other"});
+  RegisterSuccessFormat({"jpeg"});
+
+  std::string asset = "asset";
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(registry_->GetFormat(input), IsOkAndHolds(testing::NotNull()));
+}
+
+TEST_F(FormatRegistryTest, MediaTypeAndAssetNotSupported) {
+  RegisterSuccessFormat({"other"});
+
+  std::string asset = "asset";
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(registry_->GetFormat("jpeg", input),
+              StatusIs(absl::StatusCode::kUnimplemented,
+                       HasSubstr("Unsupported media type: jpeg")));
+}
+
+TEST_F(FormatRegistryTest, MediaTypeAndAssetSingleFormat) {
+  RegisterSuccessFormat({"other"});
+  RegisterFailFormat({"jpeg"});
+
+  std::string asset = "asset";
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(registry_->GetFormat("jpeg", input),
+              IsOkAndHolds(testing::NotNull()));
+}
+
+TEST_F(FormatRegistryTest, MediaTypeAndAssetMultipleFormat) {
+  RegisterSuccessFormat({"other"});
+  RegisterFailFormat({"jpeg"});
+  RegisterSuccessFormat({"jpeg"});
+
+  std::string asset = "asset";
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(registry_->GetFormat("jpeg", input),
+              IsOkAndHolds(testing::NotNull()));
+}
+
+TEST_F(FormatRegistryTest, MediaTypeAndAssetNoApplicableFormats) {
+  RegisterSuccessFormat({"other"});
+  RegisterFailFormat({"jpeg"});
+  RegisterFailFormat({"jpeg"});
+
+  std::string asset = "asset";
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(registry_->GetFormat("jpeg", input),
+              StatusIs(absl::StatusCode::kUnimplemented,
+                       HasSubstr("No applicable Format found")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/riff/BUILD b/formats/riff/BUILD
new file mode 100644
index 0000000..df9c29a
--- /dev/null
+++ b/formats/riff/BUILD
@@ -0,0 +1,181 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "chunk_header",
+    hdrs = ["chunk_header.h"],
+    deps = [
+        ":constants",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_reading",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "reader",
+    srcs = ["reader.cc"],
+    hdrs = ["reader.h"],
+    deps = [
+        ":chunk_header",
+        ":constants",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "chunk_header_test",
+    srcs = ["chunk_header_test.cc"],
+    deps = [
+        ":chunk_header",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_test(
+    name = "reader_test",
+    srcs = ["reader_test.cc"],
+    deps = [
+        ":create_riff",
+        ":reader",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    deps = [
+        ":create_riff",
+        ":extractor",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor_result",
+        "//testing:jumbf_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "create_riff",
+    testonly = 1,
+    srcs = ["create_riff.cc"],
+    hdrs = ["create_riff.h"],
+    deps = [
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":constants",
+        ":reader",
+        "//constants:labels",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        ":chunk_header",
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/riff/assessor.cc b/formats/riff/assessor.cc
new file mode 100644
index 0000000..890165a
--- /dev/null
+++ b/formats/riff/assessor.cc
@@ -0,0 +1,41 @@
+// 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 "formats/riff/assessor.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "formats/riff/chunk_header.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+absl::StatusOr<bool> RiffAssessor::IsSupported(riegeli::Reader& input) const {
+  int64_t starting_position = input.pos();
+
+  ChunkHeader riff_chunk_header;
+  ABSL_RETURN_IF_ERROR(riff_chunk_header.Read(input));
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to starting position"));
+  }
+
+  return riff_chunk_header.id == "RIFF";
+}
+
+}  // namespace credentio
diff --git a/formats/riff/assessor.h b/formats/riff/assessor.h
new file mode 100644
index 0000000..b7c248b
--- /dev/null
+++ b/formats/riff/assessor.h
@@ -0,0 +1,34 @@
+// 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_FORMATS_RIFF_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Assessor for the RIFF image format.
+class RiffAssessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_ASSESSOR_H_
diff --git a/formats/riff/assessor_test.cc b/formats/riff/assessor_test.cc
new file mode 100644
index 0000000..8fb2f50
--- /dev/null
+++ b/formats/riff/assessor_test.cc
@@ -0,0 +1,68 @@
+// 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 "formats/riff/assessor.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(IsSupportedTest, ErrorTooFewBytes) {
+  std::string image = "\xab";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(
+      RiffAssessor().IsSupported(input),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof")));
+}
+
+TEST(IsSupportedTest, FalseForInvalidBeginning) {
+  std::string image = "this_is_not_a_riff";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(RiffAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, ValidStartingBytes) {
+  std::string image = "RIFF_1234";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(RiffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidStartingBytesAtOffset2) {
+  std::string image = "abRIFF_1234";
+  riegeli::StringReader<> input(image);
+
+  // Invalid at 0
+  EXPECT_THAT(RiffAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(RiffAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/riff/chunk_header.h b/formats/riff/chunk_header.h
new file mode 100644
index 0000000..be4132c
--- /dev/null
+++ b/formats/riff/chunk_header.h
@@ -0,0 +1,73 @@
+// 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_FORMATS_RIFF_CHUNK_HEADER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CHUNK_HEADER_H_
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/strings/substitute.h"
+#include "formats/riff/constants.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_reading.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+
+// Header of a chunk in a RIFF file.
+struct ChunkHeader {
+  std::string id;      // 4-character chunk ID.
+  uint32_t data_size;  // Size of the chunk data, not including the header.
+
+  static uint64_t header_size() { return kRiffIdSize + sizeof(uint32_t); }
+  absl::Status Read(riegeli::Reader& input) {
+    if (!input.Read(kRiffIdSize, id)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("kUnexpectedEof; chunk_id"));
+    }
+    if (!riegeli::ReadLittleEndian<uint32_t>(input, data_size)) {
+      return input.StatusOrAnnotate(
+          absl::DataLossError("kUnexpectedEof; chunk_size"));
+    }
+    return absl::OkStatus();
+  }
+  absl::Status Write(riegeli::Writer& output) {
+    if (id.size() != kRiffIdSize) {
+      return absl::InvalidArgumentError(absl::Substitute(
+          "chunk_id size is $0, expected $1", id.size(), kRiffIdSize));
+    }
+    if (!output.Write(id)) {
+      return output.StatusOrAnnotate(
+          absl::DataLossError("kDataLoss; chunk_id"));
+    }
+    if (!riegeli::WriteLittleEndian<uint32_t>(data_size, output)) {
+      return output.StatusOrAnnotate(
+          absl::DataLossError("kDataLoss; chunk_size"));
+    }
+    return absl::OkStatus();
+  }
+
+  uint64_t pad_size() const { return (data_size % 2 == 1) ? 1 : 0; }
+  uint64_t chunk_size() const {
+    return static_cast<uint64_t>(data_size) + 8 + pad_size();
+  }
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CHUNK_HEADER_H_
diff --git a/formats/riff/chunk_header_test.cc b/formats/riff/chunk_header_test.cc
new file mode 100644
index 0000000..df012ff
--- /dev/null
+++ b/formats/riff/chunk_header_test.cc
@@ -0,0 +1,163 @@
+// 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 "formats/riff/chunk_header.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+
+TEST(ChunkHeaderTest, HeaderSize) {
+  EXPECT_THAT(ChunkHeader::header_size(), Eq(8));
+}
+
+TEST(ChunkHeaderTest, PadSize) {
+  ChunkHeader header;
+  header.data_size = 0;
+  EXPECT_THAT(header.pad_size(), Eq(0));
+  header.data_size = 1;
+  EXPECT_THAT(header.pad_size(), Eq(1));
+  header.data_size = 2;
+  EXPECT_THAT(header.pad_size(), Eq(0));
+  header.data_size = 1023;
+  EXPECT_THAT(header.pad_size(), Eq(1));
+  header.data_size = 1024;
+  EXPECT_THAT(header.pad_size(), Eq(0));
+}
+
+TEST(ChunkHeaderTest, ChunkSize) {
+  ChunkHeader header;
+  header.data_size = 0;
+  EXPECT_THAT(header.chunk_size(), Eq(8));  // header only
+  header.data_size = 1;
+  EXPECT_THAT(header.chunk_size(), Eq(10));  // header + data + pad
+  header.data_size = 2;
+  EXPECT_THAT(header.chunk_size(), Eq(10));  // header + data
+  header.data_size = 1023;
+  EXPECT_THAT(header.chunk_size(), Eq(1032));  // 8 + 1023 + 1
+  header.data_size = 1024;
+  EXPECT_THAT(header.chunk_size(), Eq(1032));  // 8 + 1024
+}
+
+TEST(ChunkHeaderTest, ChunkSizeOverflow) {
+  ChunkHeader header;
+  header.data_size = 0xFFFFFFF8;
+  EXPECT_THAT(header.chunk_size(), Eq(0xFFFFFFF8ULL + 8));
+}
+
+TEST(ChunkHeaderTest, ReadSuccess) {
+  // "TEST" ID, size 12 (0x0000000C in little-endian)
+  const unsigned char data[] = {'T', 'E', 'S', 'T', 0x0C, 0x00, 0x00, 0x00};
+  std::string data_str(reinterpret_cast<const char*>(data), sizeof(data));
+  riegeli::StringReader input(data_str);
+
+  ChunkHeader header;
+  EXPECT_THAT(header.Read(input), IsOk());
+  EXPECT_THAT(header.id, Eq("TEST"));
+  EXPECT_THAT(header.data_size, Eq(12));
+}
+
+TEST(ChunkHeaderTest, ReadSuccessOddSize) {
+  // "ODDT" ID, size 13 (0x0000000D in little-endian)
+  const unsigned char data[] = {'O', 'D', 'D', 'T', 0x0D, 0x00, 0x00, 0x00};
+  std::string data_str(reinterpret_cast<const char*>(data), sizeof(data));
+  riegeli::StringReader input(data_str);
+
+  ChunkHeader header;
+  EXPECT_THAT(header.Read(input), IsOk());
+  EXPECT_THAT(header.id, Eq("ODDT"));
+  EXPECT_THAT(header.data_size, Eq(13));
+}
+
+TEST(ChunkHeaderTest, ReadInputTooShortForId) {
+  const unsigned char data[] = {'T', 'E', 'S'};
+  std::string data_str(reinterpret_cast<const char*>(data), sizeof(data));
+  riegeli::StringReader input(data_str);
+
+  ChunkHeader header;
+  EXPECT_THAT(header.Read(input), StatusIs(absl::StatusCode::kDataLoss));
+}
+
+TEST(ChunkHeaderTest, ReadInputTooShortForSize) {
+  const unsigned char data[] = {'T', 'E', 'S', 'T', 0x0C, 0x00, 0x00};
+  std::string data_str(reinterpret_cast<const char*>(data), sizeof(data));
+  riegeli::StringReader input(data_str);
+
+  ChunkHeader header;
+  EXPECT_THAT(header.Read(input), StatusIs(absl::StatusCode::kDataLoss));
+}
+
+TEST(ChunkHeaderTest, WriteSuccess) {
+  ChunkHeader header;
+  header.id = "WAVE";
+  header.data_size = 42;  // 0x2A
+
+  std::string output_str;
+  riegeli::StringWriter output(&output_str);
+  EXPECT_THAT(header.Write(output), IsOk());
+  EXPECT_TRUE(output.Close());
+
+  const unsigned char expected_data[] = {'W',  'A',  'V',  'E',
+                                         0x2A, 0x00, 0x00, 0x00};
+  EXPECT_THAT(output_str,
+              Eq(std::string(reinterpret_cast<const char*>(expected_data),
+                             sizeof(expected_data))));
+}
+
+TEST(ChunkHeaderTest, WriteSuccessOddSize) {
+  ChunkHeader header;
+  header.id = "FMT ";
+  header.data_size = 17;  // 0x11
+
+  std::string output_str;
+  riegeli::StringWriter output(&output_str);
+  EXPECT_THAT(header.Write(output), IsOk());
+  EXPECT_TRUE(output.Close());
+
+  const unsigned char expected_data[] = {'F',  'M',  'T',  ' ',
+                                         0x11, 0x00, 0x00, 0x00};
+  EXPECT_THAT(output_str,
+              Eq(std::string(reinterpret_cast<const char*>(expected_data),
+                             sizeof(expected_data))));
+}
+
+TEST(ChunkHeaderTest, WriteInvalidIdLength) {
+  ChunkHeader header;
+  header.id = "TOO_LONG";
+  header.data_size = 10;
+
+  std::string output_str;
+  riegeli::StringWriter output(&output_str);
+  EXPECT_THAT(header.Write(output),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_TRUE(output.Close());
+  EXPECT_TRUE(output_str.empty());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/riff/constants.h b/formats/riff/constants.h
new file mode 100644
index 0000000..eeec51f
--- /dev/null
+++ b/formats/riff/constants.h
@@ -0,0 +1,35 @@
+// 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_FORMATS_RIFF_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CONSTANTS_H_
+
+#include <cstdint>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+constexpr uint32_t kRiffIdSize = 4;
+constexpr uint32_t kRiffSizeSize = 4;
+constexpr uint32_t kRiffFormSize = 4;
+
+constexpr absl::string_view kRiffChunkIdRiff = "RIFF";
+constexpr absl::string_view kRiffChunkIdList = "LIST";
+constexpr absl::string_view kRiffChunkIdC2pa = "C2PA";
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CONSTANTS_H_
diff --git a/formats/riff/create_riff.cc b/formats/riff/create_riff.cc
new file mode 100644
index 0000000..1b66cd1
--- /dev/null
+++ b/formats/riff/create_riff.cc
@@ -0,0 +1,54 @@
+// 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 "formats/riff/create_riff.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio_riff {
+
+std::string Chunk(absl::string_view chunk_id, absl::string_view data) {
+  CHECK_EQ(chunk_id.length(), 4);
+
+  // RIFF is little-endian.
+  char size_buf[4];
+  riegeli::WriteLittleEndian<uint32_t>(static_cast<uint32_t>(data.size()),
+                                       size_buf);
+  absl::string_view size_bytes(size_buf, 4);
+
+  // Pad byte (not included in the length value).
+  absl::string_view pad = (data.length() % 2 == 1) ? absl::string_view("\0", 1)
+                                                   : absl::string_view();
+
+  return absl::StrCat(chunk_id, size_bytes, data, pad);
+}
+
+std::string RiffChunk(absl::string_view form, absl::string_view chunks) {
+  CHECK_EQ(form.length(), 4);
+  return Chunk("RIFF", absl::StrCat(form, chunks));
+}
+
+std::string ListChunk(absl::string_view type, absl::string_view chunks) {
+  CHECK_EQ(type.length(), 4);
+  return Chunk("LIST", absl::StrCat(type, chunks));
+}
+
+}  // namespace credentio_riff
diff --git a/formats/riff/create_riff.h b/formats/riff/create_riff.h
new file mode 100644
index 0000000..983ab7c
--- /dev/null
+++ b/formats/riff/create_riff.h
@@ -0,0 +1,36 @@
+// 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_FORMATS_RIFF_CREATE_RIFF_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CREATE_RIFF_H_
+
+#include <string>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio_riff {
+
+// Creates a RIFF chunk with the given ID and data.
+std::string Chunk(absl::string_view chunk_id, absl::string_view data);
+
+// Creates a RIFF chunk with ID "RIFF", with the given form and subchunks.
+std::string RiffChunk(absl::string_view form, absl::string_view chunks);
+
+// Creates a RIFF chunk with ID "LIST", with the given type and subchunks.
+std::string ListChunk(absl::string_view type, absl::string_view chunks);
+
+}  // namespace credentio_riff
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_CREATE_RIFF_H_
diff --git a/formats/riff/extractor.cc b/formats/riff/extractor.cc
new file mode 100644
index 0000000..ccaabaa
--- /dev/null
+++ b/formats/riff/extractor.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.
+//
+
+// RIFF format information:
+//   https://www.loc.gov/preservation/digital/formats/fdd/fdd000025.shtml
+//   https://johnloomis.org/cpe102/asgn/asgn1/riff.html
+//   https://www.tactilemedia.com/info/MCI_Control_Info.html
+// C2PA embedding:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_riff_based_assets
+#include "formats/riff/extractor.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/riff/constants.h"
+#include "formats/riff/reader.h"
+#include "jumbf/utils.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+absl::StatusOr<std::vector<AssetBox>> ExtractBoxesInternal(
+    riegeli::Reader& reader, int64_t end_offset) {
+  std::vector<AssetBox> result;
+  bool first = true;
+  std::vector<uint64_t> container_ends;
+
+  auto status = IterateOverRiffChunks(
+      reader,
+      [&result, &first, &container_ends,
+       &reader](const RiffChunk& chunk) -> absl::StatusOr<bool> {
+        while (!container_ends.empty() &&
+               chunk.offset >= container_ends.back()) {
+          container_ends.pop_back();
+        }
+        int depth = container_ends.size();
+
+        if (depth == 0) {
+          if (first) {
+            if (chunk.id != kRiffChunkIdRiff) {
+              return absl::InvalidArgumentError("RIFF chunk not found");
+            }
+            first = false;
+          }
+
+          result.push_back({
+              .identifier = chunk.id,
+              .byte_range = {.offset = chunk.offset, .length = 12},
+          });
+
+          if (chunk.HasSubchunks()) {
+            container_ends.push_back(chunk.offset + chunk.length);
+          }
+          return true;
+        }
+
+        if (depth == 1) {
+          if (chunk.id == kRiffChunkIdC2pa && IsRiffChunkTooLarge(chunk)) {
+            return absl::InvalidArgumentError(
+                "RIFF C2PA chunk is too large to extract");
+          }
+
+          result.push_back({
+              .identifier = chunk.id,
+              .byte_range = {.offset = chunk.offset, .length = chunk.length},
+          });
+
+          if (chunk.HasSubchunks()) {
+            reader.Seek(chunk.offset + chunk.length);
+          }
+          return true;
+        }
+
+        return absl::InternalError("Unexpected depth in RIFF parser");
+      },
+      end_offset);
+
+  ABSL_RETURN_IF_ERROR(status);
+  return result;
+}
+
+// Returns the C2PA subchunk within the RIFF chunk, if any.
+absl::StatusOr<std::optional<RiffChunk>> FindC2paChunk(riegeli::Reader& reader,
+                                                       int64_t end_offset) {
+  std::optional<RiffChunk> c2pa_chunk;
+  RiffChunk riff_chunk;
+  bool first = true;
+
+  auto status = IterateOverRiffChunks(
+      reader,
+      [&c2pa_chunk, &first,
+       &riff_chunk](const RiffChunk& chunk) -> absl::StatusOr<bool> {
+        if (first) {
+          if (chunk.id != kRiffChunkIdRiff) {
+            return absl::InvalidArgumentError(
+                "Input does not start with RIFF chunk");
+          }
+          riff_chunk = chunk;
+          first = false;
+          return true;
+        }
+        if (chunk.offset >= riff_chunk.offset + riff_chunk.length) {
+          return false;  // Stop iterating, we finished the first RIFF chunk
+        }
+        if (chunk.id == kRiffChunkIdC2pa) {
+          if (IsRiffChunkTooLarge(chunk)) {
+            return absl::InvalidArgumentError(
+                "RIFF C2PA chunk is too large to extract");
+          }
+          c2pa_chunk = chunk;
+          return false;  // Found it, stop iterating
+        }
+        return true;
+      },
+      end_offset);
+
+  ABSL_RETURN_IF_ERROR(status);
+  if (first) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return c2pa_chunk;
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> RiffExtractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  ABSL_ASSIGN_OR_RETURN(std::optional<RiffChunk> c2pa_chunk,
+                        FindC2paChunk(input, /*end_offset=*/-1));
+  if (!c2pa_chunk.has_value()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  int64_t offset = c2pa_chunk->data_offset;
+  int64_t length = c2pa_chunk->data_length;
+  if (!input.Seek(offset) || input.pos() != offset) {
+    return absl::InvalidArgumentError(
+        "The input could not be reset to the manifest store location.");
+  }
+  std::string result;
+  if (!input.Read(length, result)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read manifest store"));
+  }
+  return std::move(result);
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+RiffExtractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                            ExtractOptions options) const {
+  ABSL_ASSIGN_OR_RETURN(std::optional<RiffChunk> c2pa_chunk,
+                        FindC2paChunk(input, options.end_offset));
+  if (!c2pa_chunk.has_value()) {
+    if (options.requires_c2pa) {
+      return absl::NotFoundError("No manifest store found");
+    }
+    return std::nullopt;
+  }
+  return ByteRange{
+      .offset = c2pa_chunk->offset,
+      .length = c2pa_chunk->length,
+  };
+}
+
+absl::StatusOr<std::vector<AssetBox>> RiffExtractor::ExtractBoxes(
+    riegeli::Reader& input, ExtractOptions options) const {
+  ABSL_ASSIGN_OR_RETURN(std::vector<AssetBox> boxes,
+                        ExtractBoxesInternal(input, options.end_offset));
+  if (!options.requires_c2pa) {
+    return std::move(boxes);
+  }
+
+  int64_t c2pa_count = 0;
+  for (const auto& box : boxes) {
+    if (box.identifier == kRiffChunkIdC2pa) {
+      c2pa_count++;
+      if (c2pa_count > 1) {
+        return absl::NotFoundError("Multiple manifest stores found");
+      }
+    }
+  }
+  if (c2pa_count == 0) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return std::move(boxes);
+}
+
+bool RiffExtractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+
+}  // namespace credentio
diff --git a/formats/riff/extractor.h b/formats/riff/extractor.h
new file mode 100644
index 0000000..7e9cfe8
--- /dev/null
+++ b/formats/riff/extractor.h
@@ -0,0 +1,52 @@
+// 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_FORMATS_RIFF_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Extracts C2PA Manifest Store from a RIFF based file (e.g., webp).
+class RiffExtractor : public FormatExtractor {
+ public:
+  RiffExtractor() = default;
+  ~RiffExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_EXTRACTOR_H_
diff --git a/formats/riff/extractor_test.cc b/formats/riff/extractor_test.cc
new file mode 100644
index 0000000..ff469c2
--- /dev/null
+++ b/formats/riff/extractor_test.cc
@@ -0,0 +1,386 @@
+// 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 "formats/riff/extractor.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor_result.h"
+#include "formats/riff/create_riff.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/jumbf_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_riff::Chunk;
+using ::credentio_riff::ListChunk;
+using ::credentio_riff::RiffChunk;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+
+constexpr absl::string_view kManifestStore = "1234567890";
+
+std::vector<AssetBox> CreateBoxes(
+    std::vector<std::pair<std::string, uint32_t>> expected) {
+  uint64_t offset = 0;
+  std::vector<AssetBox> boxes;
+  for (const auto& name_and_size : expected) {
+    boxes.push_back(AssetBox{
+        .identifier = name_and_size.first,
+        .byte_range = {.offset = offset, .length = name_and_size.second},
+    });
+    offset += name_and_size.second;
+  }
+  return boxes;
+}
+
+struct TestCase {
+  std::string name;
+  std::string contents;
+  absl::StatusOr<ExtractorResult> result;
+};
+
+class ExtractorTest : public testing::TestWithParam<TestCase> {};
+
+INSTANTIATE_TEST_SUITE_P(
+    ExtractorTests, ExtractorTest,
+    testing::ValuesIn({
+        TestCase{
+            .name = "EmptyContent",
+            .contents = "",
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "EmptyRiffChunk",
+            .contents = RiffChunk("ABCD", ""),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "VariousChunksNoC2pa",
+            .contents = RiffChunk(
+                "WEBP",
+                absl::StrCat(Chunk("ABCD", "some data"), Chunk("HIJK", "etc"),
+                             Chunk("DEFG", "more irrelevant data"))),
+            .result = absl::NotFoundError("No manifest store found"),
+        },
+        TestCase{
+            .name = "C2paChunkTooLarge",
+            .contents = RiffChunk(
+                "WEBP",
+                absl::StrCat(Chunk("ABCD", std::string(100000, 'X')),
+                             Chunk("C2PA",
+                                   std::string((1024 * 1024 * 10) + 1, 'Z')))),
+            .result = absl::InvalidArgumentError(
+                "RIFF C2PA chunk is too large to extract"),
+        },
+        TestCase{
+            .name = "SingleC2paChunk",
+            .contents = RiffChunk("WEBP", Chunk("C2PA", kManifestStore)),
+            .result =
+                ExtractorResult{
+                    .manifest_store = std::string(kManifestStore),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 12,
+                                                                 .length = 18},
+                            .boxes = CreateBoxes({{"RIFF", /*size=*/12},
+                                                  {"C2PA", /*size=*/18}}),
+                        },
+                },
+        },
+        TestCase{
+            .name = "VariousChunksIncludingC2pa",
+            .contents =
+                RiffChunk(
+                    "WEBP",
+                    absl::StrCat(
+                        Chunk("ABCD",
+                              "123456789"),  // odd length, will have pad byte
+                        Chunk("EFGH", std::string(100000, 'Z')),  // large chunk
+                        Chunk("C2PA", kManifestStore), Chunk("DEFG", "12"))),
+            .result =
+                ExtractorResult{
+                    .manifest_store = std::string(kManifestStore),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location =
+                                ByteRange{.offset = 100038, .length = 18},
+                            .boxes = CreateBoxes({{"RIFF", /*size=*/12},
+                                                  {"ABCD", /*size=*/18},
+                                                  {"EFGH", /*size=*/100008},
+                                                  {"C2PA", /*size=*/18},
+                                                  {"DEFG", /*size=*/10}}),
+                        }},
+        },
+        TestCase{
+            .name = "ContainingListChunk",
+            .contents = RiffChunk(
+                "WEBP", absl::StrCat(Chunk("ABCD", "1234"),
+                                     ListChunk("DATA", Chunk("HIJK", "1234")),
+                                     Chunk("DEFG", "1234"),
+                                     Chunk("C2PA", kManifestStore))),
+            .result =
+                ExtractorResult{
+                    .manifest_store = std::string(kManifestStore),
+                    .asset_byte_info =
+                        {
+                            .manifest_store_location = ByteRange{.offset = 60,
+                                                                 .length = 18},
+                            .boxes = CreateBoxes({{"RIFF", /*size=*/12},
+                                                  {"ABCD", /*size=*/12},
+                                                  {"LIST", /*size=*/24},
+                                                  {"DEFG", /*size=*/12},
+                                                  {"C2PA", /*size=*/18}}),
+                        }},
+        },
+    }),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+TEST(ExtractorOverflowTest, DeeplyNestedRiffWithoutC2pa) {
+  std::string contents = Chunk("NMBR", "1234");
+  for (int32_t i = 0; i < 200; ++i) {
+    contents = ListChunk("LAYR", contents);
+  }
+  contents = RiffChunk("WEBP", contents);
+
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(RiffExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No manifest store found")));
+}
+
+TEST_P(ExtractorTest, ExtractManifestStore) {
+  const TestCase& test_case = GetParam();
+  riegeli::StringReader<absl::string_view> input(test_case.contents);
+
+  absl::StatusOr<std::string> result =
+      RiffExtractor().ExtractManifestStore(input);
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_EQ(*result, expected_result->manifest_store);
+  } else {
+    EXPECT_THAT(result.status(),
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) {
+  const TestCase& test_case = GetParam();
+  std::string contents = "padding" + test_case.contents;
+  riegeli::StringReader<> input(contents);
+
+  ASSERT_TRUE(input.Seek(7));
+  absl::StatusOr<std::string> result =
+      RiffExtractor().ExtractManifestStore(input);
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_EQ(*result, expected_result->manifest_store);
+  } else {
+    EXPECT_THAT(result.status(),
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocation) {
+  const TestCase& test_case = GetParam();
+  riegeli::StringReader<absl::string_view> input(test_case.contents);
+
+  absl::StatusOr<std::optional<ByteRange>> result =
+      RiffExtractor().ExtractManifestStoreLocation(input, {});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_EQ(*result,
+              expected_result->asset_byte_info.manifest_store_location);
+  } else {
+    EXPECT_THAT(result.status(),
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxes) {
+  const TestCase& test_case = GetParam();
+  riegeli::StringReader<absl::string_view> input(test_case.contents);
+
+  absl::StatusOr<std::vector<AssetBox>> result =
+      RiffExtractor().ExtractBoxes(input, {});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_THAT(*result, expected_result->asset_byte_info.boxes);
+  } else {
+    EXPECT_THAT(result.status(),
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffixPadded) {
+  const TestCase& test_case = GetParam();
+  std::string contents = test_case.contents + "padding8";
+  riegeli::StringReader<> input(contents);
+
+  absl::StatusOr<std::optional<ByteRange>> result =
+      RiffExtractor().ExtractManifestStoreLocation(
+          input, {.end_offset = static_cast<int64_t>(contents.size()) - 8});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_EQ(*result,
+              expected_result->asset_byte_info.manifest_store_location);
+  } else {
+    EXPECT_THAT(result.status(),
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxesWithSuffixPadded) {
+  const TestCase& test_case = GetParam();
+  std::string contents = test_case.contents + "padding8";
+  riegeli::StringReader<> input(contents);
+
+  absl::StatusOr<std::vector<AssetBox>> result = RiffExtractor().ExtractBoxes(
+      input, {.end_offset = static_cast<int64_t>(contents.size()) - 8});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_THAT(*result, expected_result->asset_byte_info.boxes);
+  } else {
+    EXPECT_THAT(result.status(),
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST(RiffExtractorTest, IsManifestStore) {
+  EXPECT_TRUE(RiffExtractor().MightBeC2paManifestStore(
+      CreateStartOfManifestStorePayload()));
+}
+
+TEST(RiffExtractorTest, ExtractManifestStoreMultipleRiffChunks) {
+  std::string contents =
+      absl::StrCat(RiffChunk("WEBP", Chunk("C2PA", kManifestStore)),
+                   RiffChunk("BLAH", Chunk("ABCD", "second riff chunk data")));
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(RiffExtractor().ExtractManifestStore(input),
+              IsOkAndHolds(kManifestStore));
+}
+
+TEST(RiffExtractorTest, ExtractManifestStoreC2paInSecondRiffChunk) {
+  std::string contents =
+      absl::StrCat(RiffChunk("WEBP", Chunk("ABCD", "first riff chunk data")),
+                   RiffChunk("BLAH", Chunk("C2PA", kManifestStore)));
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(RiffExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound, "No manifest store found"));
+}
+
+TEST(RiffExtractorTest, ExtractBoxesNonRiffTopLevelChunk) {
+  std::string contents =
+      absl::StrCat(RiffChunk("WEBP", Chunk("ABCD", "first riff chunk data")),
+                   Chunk("BLAH", "non-riff top-level chunk data"));
+  riegeli::StringReader<> input(contents);
+
+  auto boxes_or = RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false});
+  ASSERT_THAT(boxes_or.status(), ::absl_testing::IsOk());
+  std::vector<AssetBox> boxes = *boxes_or;
+
+  std::vector<std::string> box_names;
+  for (const auto& box : boxes) {
+    box_names.push_back(box.identifier);
+  }
+  EXPECT_THAT(box_names, ElementsAre("RIFF", "ABCD", "BLAH"));
+}
+
+TEST(RiffExtractorTest, MultipleC2paChunks) {
+  std::string contents =
+      RiffChunk("WEBP", absl::StrCat(Chunk("C2PA", kManifestStore),
+                                     Chunk("C2PA", kManifestStore)));
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      RiffExtractor().ExtractBoxes(input, {.requires_c2pa = true}),
+      StatusIs(absl::StatusCode::kNotFound, "Multiple manifest stores found"));
+}
+
+TEST(RiffExtractorTest, ExtractManifestStoreDoesNotStartWithRiff) {
+  std::string contents = Chunk("ABCD", "some data");
+  riegeli::StringReader<> input(contents);
+  EXPECT_THAT(RiffExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "Input does not start with RIFF chunk"));
+}
+
+TEST(RiffExtractorTest, ExtractBoxesDoesNotStartWithRiff) {
+  std::string contents = Chunk("ABCD", "some data");
+  riegeli::StringReader<> input(contents);
+  EXPECT_THAT(
+      RiffExtractor().ExtractBoxes(input, {}),
+      StatusIs(absl::StatusCode::kInvalidArgument, "RIFF chunk not found"));
+}
+
+TEST(RiffExtractorTest, ExtractBoxesMultipleRiffChunks) {
+  std::string contents =
+      absl::StrCat(RiffChunk("WEBP", Chunk("ABCD", "first riff chunk data")),
+                   RiffChunk("BLAH", Chunk("EFGH", "second riff chunk data")));
+  riegeli::StringReader<> input(contents);
+
+  auto boxes_or = RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false});
+  ASSERT_THAT(boxes_or.status(), ::absl_testing::IsOk());
+  std::vector<AssetBox> boxes = *boxes_or;
+
+  std::vector<std::string> box_names;
+  for (const auto& box : boxes) {
+    box_names.push_back(box.identifier);
+  }
+  EXPECT_THAT(box_names, ElementsAre("RIFF", "ABCD", "RIFF", "EFGH"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/riff/format.cc b/formats/riff/format.cc
new file mode 100644
index 0000000..f2c620f
--- /dev/null
+++ b/formats/riff/format.cc
@@ -0,0 +1,35 @@
+// 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 "formats/riff/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+#include "formats/riff/assessor.h"
+#include "formats/riff/extractor.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateRiffFormat() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<RiffAssessor>(),
+      .extractor = std::make_unique<RiffExtractor>(),
+      .mime_types = {"image/webp", "audio/wav"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/riff/format.h b/formats/riff/format.h
new file mode 100644
index 0000000..b1a1a99
--- /dev/null
+++ b/formats/riff/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_RIFF_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateRiffFormat();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_FORMAT_H_
diff --git a/formats/riff/reader.cc b/formats/riff/reader.cc
new file mode 100644
index 0000000..923e302
--- /dev/null
+++ b/formats/riff/reader.cc
@@ -0,0 +1,154 @@
+// 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 "formats/riff/reader.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/substitute.h"
+#include "formats/riff/chunk_header.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;  // 10 MiB
+
+absl::StatusOr<bool> IterateOverRiffChunksInternal(
+    riegeli::Reader& reader, uint64_t end_offset,
+    RiffChunkProcessor processor) {
+  while (reader.pos() + ChunkHeader::header_size() <= end_offset) {
+    uint64_t chunk_offset = reader.pos();
+    ChunkHeader chunk_header;
+    ABSL_RETURN_IF_ERROR(chunk_header.Read(reader));
+
+    RiffChunk chunk = {
+        .offset = chunk_offset,
+        .length = chunk_header.chunk_size(),
+        .id = chunk_header.id,
+        .type = "",
+        .data_offset = chunk_offset + chunk_header.header_size(),
+        .data_length = chunk_header.data_size,
+    };
+
+    if (chunk.HasSubchunks()) {
+      if (chunk.data_length < 4) {
+        return absl::InvalidArgumentError(
+            "RIFF/LIST chunk too short to contain a form type");
+      }
+      if (!reader.Read(4, chunk.type)) {
+        return reader.StatusOrAnnotate(
+            absl::DataLossError("kUnexpectedEof; type"));
+      }
+      chunk.data_offset += 4;
+      chunk.data_length -= 4;
+    }
+
+    if (chunk.length < ChunkHeader::header_size()) {
+      return absl::InvalidArgumentError("Invalid RIFF chunk size");
+    }
+    if (chunk.length > end_offset - chunk.offset) {
+      return absl::InvalidArgumentError(
+          "RIFF chunk extends beyond the end of the file");
+    }
+    uint64_t chunk_end_offset = chunk.offset + chunk.length;
+
+    bool continue_processing;
+    if (auto result = processor(chunk); result.ok()) {
+      continue_processing = *result;
+    } else {
+      return result.status();
+    }
+    if (!continue_processing) {
+      return false;
+    }
+
+    if (reader.pos() < chunk.data_offset + chunk.data_length &&
+        chunk.HasSubchunks()) {
+      if (auto sub_continue_or = IterateOverRiffChunksInternal(
+              reader, chunk_end_offset, processor);
+          sub_continue_or.ok()) {
+        continue_processing = *sub_continue_or;
+      } else {
+        return sub_continue_or.status();
+      }
+      if (!continue_processing) {
+        return false;
+      }
+    }
+
+    reader.Seek(chunk_end_offset);
+    if (reader.pos() <= chunk_offset) {
+      return absl::InternalError(
+          "RIFF parser detected a read stall (infinite loop prevented)");
+    }
+  }
+  return true;
+}
+
+}  // namespace
+
+absl::Status IterateOverRiffChunks(riegeli::Reader& reader,
+                                   RiffChunkProcessor processor,
+                                   int64_t end_offset) {
+  if (!reader.SupportsSize() || !reader.Size().has_value()) {
+    return absl::InvalidArgumentError(
+        "manifest store not embedded: reader size cannot be determined");
+  }
+  uint64_t actual_end_offset =
+      end_offset < 0 ? *reader.Size()
+                     : std::min(static_cast<uint64_t>(*reader.Size()),
+                                static_cast<uint64_t>(end_offset));
+
+  if ((actual_end_offset - reader.pos()) == 0) {
+    return absl::OkStatus();
+  }
+
+  return IterateOverRiffChunksInternal(reader, actual_end_offset, processor)
+      .status();
+}
+
+absl::StatusOr<std::string> ReadRiffChunkData(riegeli::Reader& reader,
+                                              const RiffChunk& chunk) {
+  if (IsRiffChunkTooLarge(chunk)) {
+    return absl::InvalidArgumentError(
+        absl::Substitute("RIFF C2PA chunk is too large to extract ($0 > $1)",
+                         chunk.data_length, kMaxPayloadSize));
+  }
+
+  if (reader.pos() != chunk.data_offset) {
+    if (!reader.Seek(chunk.data_offset) || reader.pos() != chunk.data_offset) {
+      return absl::InvalidArgumentError("Failed to seek to chunk data offset");
+    }
+  }
+
+  std::string data;
+  if (!reader.Read(chunk.data_length, data)) {
+    return reader.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; data"));
+  }
+  return std::move(data);
+}
+
+bool IsRiffChunkTooLarge(const RiffChunk& chunk) {
+  return chunk.data_length > kMaxPayloadSize;
+}
+
+}  // namespace credentio
diff --git a/formats/riff/reader.h b/formats/riff/reader.h
new file mode 100644
index 0000000..6a7b000
--- /dev/null
+++ b/formats/riff/reader.h
@@ -0,0 +1,97 @@
+// 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_FORMATS_RIFF_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_READER_H_
+
+#include <cstdint>
+#include <ostream>
+#include <string>
+
+#include "absl/functional/function_ref.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "formats/riff/constants.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Information about a chunk in a RIFF file.
+struct RiffChunk {
+  uint64_t offset = 0;
+  uint64_t length = 0;
+  std::string id;    // 4-character chunk ID.
+  std::string type;  // 4-character form type (only for RIFF/LIST chunks).
+
+  uint64_t data_offset = 0;
+  uint64_t data_length = 0;
+
+  bool HasSubchunks() const {
+    return id == kRiffChunkIdRiff || id == kRiffChunkIdList;
+  }
+
+  friend bool operator==(const RiffChunk& lhs, const RiffChunk& rhs) {
+    return lhs.offset == rhs.offset && lhs.length == rhs.length &&
+           lhs.id == rhs.id && lhs.type == rhs.type &&
+           lhs.data_offset == rhs.data_offset &&
+           lhs.data_length == rhs.data_length;
+  }
+
+  friend std::ostream& operator<<(std::ostream& os, const RiffChunk& chunk) {
+    return os << absl::StrCat("RiffChunk{offset: ", chunk.offset,
+                              ", length: ", chunk.length, ", id: ", chunk.id,
+                              ", type: ", chunk.type,
+                              ", data_offset: ", chunk.data_offset,
+                              ", data_length: ", chunk.data_length, "}");
+  }
+};
+
+// The function signature for processing a RIFF chunk.
+using RiffChunkProcessor =
+    absl::FunctionRef<absl::StatusOr<bool>(const RiffChunk&)>;
+
+// Iterates over all RIFF chunks in the given reader, calling the given
+// `processor` function for each chunk.
+//
+// The `processor` function controls the iteration by returning an
+// `absl::StatusOr<bool>`:
+// - Return `true` to continue iterating over subsequent chunks.
+// - Return `false` to stop iterating early. `IterateOverRiffChunks` will stop
+//   and return `absl::OkStatus()`.
+// - Return an error status to abort iteration immediately and propagate the
+//   error back to the caller.
+//
+// The processor can also affect iteration by seeking the reader:
+// - For container chunks (RIFF/LIST), sub-chunk iteration will start reading
+//   from wherever the processor leaves the reader.
+// - To skip processing sub-chunks of a container chunk, the processor can seek
+//   the reader to the end of the current chunk.
+// - The iterator will always reset the reader position to the end of the
+//   current chunk before proceeding to the next sibling chunk.
+absl::Status IterateOverRiffChunks(riegeli::Reader& reader,
+                                   RiffChunkProcessor processor,
+                                   int64_t end_offset = -1);
+
+// Reads the raw data for the given chunk.
+absl::StatusOr<std::string> ReadRiffChunkData(riegeli::Reader& reader,
+                                              const RiffChunk& chunk);
+
+// Returns true if the chunk is too large to read.
+bool IsRiffChunkTooLarge(const RiffChunk& chunk);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_READER_H_
diff --git a/formats/riff/reader_test.cc b/formats/riff/reader_test.cc
new file mode 100644
index 0000000..28bc933
--- /dev/null
+++ b/formats/riff/reader_test.cc
@@ -0,0 +1,335 @@
+// 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 "formats/riff/reader.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "formats/riff/create_riff.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+
+constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;  // 10 MiB
+
+std::string WriteRiffChunkMissingInvalidForm() {
+  riegeli::StringWriter writer;
+  writer.Write("RIFF");
+  riegeli::WriteLittleEndian<uint32_t>(2, writer);
+  writer.Write("AB");
+  writer.Close();
+  return writer.dest();
+}
+
+absl::StatusOr<std::vector<RiffChunk>> GetChunks(riegeli::Reader& reader) {
+  std::vector<RiffChunk> chunks;
+  ABSL_RETURN_IF_ERROR(
+      IterateOverRiffChunks(reader, [&chunks](const RiffChunk& chunk) {
+        chunks.push_back(chunk);
+        return true;
+      }));
+  return chunks;
+}
+
+absl::StatusOr<std::vector<std::string>> ReadChunkPayloads(
+    riegeli::Reader& reader, const std::vector<RiffChunk>& chunks) {
+  std::vector<std::string> payloads;
+  for (const auto& chunk : chunks) {
+    ABSL_ASSIGN_OR_RETURN(auto payload, ReadRiffChunkData(reader, chunk));
+    payloads.push_back(payload);
+  }
+  return payloads;
+}
+
+TEST(RiffReaderUnitTest, ReadsChunks) {
+  std::string contents = credentio_riff::RiffChunk(
+      "XYZW", absl::StrCat(credentio_riff::Chunk("ABCD", "first chunk"),
+                           credentio_riff::Chunk("EFGH", "second chunk"),
+                           credentio_riff::Chunk("IJKL", "third chunk"),
+                           credentio_riff::Chunk("MNOP", "fourth chunk")));
+  riegeli::StringReader<> input(contents);
+  auto chunks_or = GetChunks(input);
+  ASSERT_THAT(chunks_or.status(), IsOk());
+  auto chunks = *chunks_or;
+  ASSERT_THAT(chunks, ElementsAre(
+                          RiffChunk{
+                              .offset = 0,
+                              .length = 92,
+                              .id = "RIFF",
+                              .type = "XYZW",
+                              .data_offset = 12,
+                              .data_length = 80,
+                          },
+                          RiffChunk{
+                              .offset = 12,
+                              .length = 20,
+                              .id = "ABCD",
+                              .type = "",
+                              .data_offset = 20,
+                              .data_length = 11,
+                          },
+                          RiffChunk{
+                              .offset = 32,
+                              .length = 20,
+                              .id = "EFGH",
+                              .type = "",
+                              .data_offset = 40,
+                              .data_length = 12,
+                          },
+                          RiffChunk{
+                              .offset = 52,
+                              .length = 20,
+                              .id = "IJKL",
+                              .type = "",
+                              .data_offset = 60,
+                              .data_length = 11,
+                          },
+                          RiffChunk{
+                              .offset = 72,
+                              .length = 20,
+                              .id = "MNOP",
+                              .type = "",
+                              .data_offset = 80,
+                              .data_length = 12,
+                          }));
+
+  // Re-reading payloads requires seeking, ReadRiffChunkData handles it.
+  EXPECT_THAT(ReadChunkPayloads(input, {chunks.begin() + 1, chunks.end()}),
+              IsOkAndHolds(ElementsAre("first chunk", "second chunk",
+                                       "third chunk", "fourth chunk")));
+}
+
+TEST(RiffReaderUnitTest, MissingChunkTypeForChunkWithSubchunks) {
+  std::string contents = WriteRiffChunkMissingInvalidForm();
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(GetChunks(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("RIFF/LIST chunk too short")));
+}
+
+TEST(ReadChunkDataTest, SuccessfulRead) {
+  std::string contents = "RIFF....WEBPVP8 ....";
+  //                     01234567890123456789
+  riegeli::StringReader<> input(contents);
+  RiffChunk chunk = {
+      .offset = 12,
+      .length = 8,
+      .id = "VP8 ",
+      .type = "",
+      .data_offset = 12,
+      .data_length = 8,
+  };
+  EXPECT_THAT(ReadRiffChunkData(input, chunk), IsOkAndHolds("VP8 ...."));
+}
+
+TEST(ReadChunkDataTest, ReadBeyondEOF) {
+  std::string contents = "RIFF....WEBPVP8 ....";
+  //                     01234567890123456789
+  riegeli::StringReader<> input(contents);
+  RiffChunk chunk = {
+      .offset = 12,
+      .length = 8,
+      .id = "VP8 ",
+      .type = "",
+      .data_offset = 12,
+      .data_length = 10,  // Reads past end of contents
+  };
+  EXPECT_THAT(
+      ReadRiffChunkData(input, chunk),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof")));
+}
+
+TEST(ReadChunkDataTest, EmptyChunk) {
+  std::string contents = "RIFF....WEBPVP8 ....";
+  riegeli::StringReader<> input(contents);
+  RiffChunk chunk = {
+      .offset = 12,
+      .length = 0,
+      .id = "VP8 ",
+      .type = "",
+      .data_offset = 12,
+      .data_length = 0,
+  };
+  EXPECT_THAT(ReadRiffChunkData(input, chunk), IsOkAndHolds(IsEmpty()));
+}
+
+TEST(ReadChunkDataTest, ReadAtEOF) {
+  std::string contents = "RIFF....WEBPVP8 ....";
+  riegeli::StringReader<> input(contents);
+  RiffChunk chunk = {
+      .offset = 20,
+      .length = 0,
+      .id = "....",
+      .type = "",
+      .data_offset = 20,
+      .data_length = 0,
+  };
+  EXPECT_THAT(ReadRiffChunkData(input, chunk), IsOkAndHolds(IsEmpty()));
+}
+
+TEST(ReadChunkDataTest, InvalidDataOffset) {
+  std::string contents = "RIFF....WEBPVP8 ....";
+  riegeli::StringReader<> input(contents);
+  RiffChunk chunk = {
+      .offset = 12,
+      .length = 8,
+      .id = "VP8 ",
+      .type = "",
+      .data_offset = 100,  // Past EOF
+      .data_length = 1,
+  };
+  EXPECT_THAT(ReadRiffChunkData(input, chunk),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Failed to seek to chunk data offset")));
+}
+
+TEST(ReadChunkDataTest, ChunkTooLarge) {
+  std::string contents = "";  // Not actually read in this test
+  riegeli::StringReader<> input(contents);
+  RiffChunk chunk = {
+      .offset = 12,
+      .length = kMaxPayloadSize + 9,
+      .id = "C2PA",
+      .type = "",
+      .data_offset = 20,
+      .data_length = kMaxPayloadSize + 1,
+  };
+  EXPECT_THAT(ReadRiffChunkData(input, chunk),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("RIFF C2PA chunk is too large to extract")));
+}
+
+TEST(RiffReaderUnitTest, ProcessorConsumesDataOfContainer) {
+  std::string contents =
+      credentio_riff::RiffChunk("WAVE", credentio_riff::ListChunk("INFO", "x"));
+  riegeli::StringReader<> input(contents);
+
+  auto processor = [&input](const RiffChunk& chunk) -> absl::StatusOr<bool> {
+    if (chunk.id == "LIST") {
+      std::string data;
+      if (!input.Read(1, data)) {
+        return absl::DataLossError("failed to read");
+      }
+      EXPECT_EQ(data, "x");
+    }
+    return true;
+  };
+
+  EXPECT_THAT(IterateOverRiffChunks(input, processor), IsOk());
+}
+
+TEST(RiffReaderUnitTest, OddContainerWithOddSubchunkAtEnd) {
+  riegeli::StringWriter writer;
+  writer.Write("RIFF");
+  riegeli::WriteLittleEndian<uint32_t>(26, writer);
+  writer.Write("WAVE");
+  writer.Write("LIST");
+  riegeli::WriteLittleEndian<uint32_t>(13, writer);
+  writer.Write("INFO");
+  writer.Write("ISFT");
+  riegeli::WriteLittleEndian<uint32_t>(1, writer);
+  writer.Write("a");
+  writer.Write('\0');  // LIST pad
+  writer.Close();
+  std::string contents = writer.dest();
+
+  riegeli::StringReader<> input(contents);
+  auto chunks_or = GetChunks(input);
+  ASSERT_THAT(chunks_or.status(), IsOk());
+  auto chunks = *chunks_or;
+  ASSERT_THAT(chunks, ElementsAre(
+                          RiffChunk{
+                              .offset = 0,
+                              .length = 34,
+                              .id = "RIFF",
+                              .type = "WAVE",
+                              .data_offset = 12,
+                              .data_length = 22,
+                          },
+                          RiffChunk{
+                              .offset = 12,
+                              .length = 22,
+                              .id = "LIST",
+                              .type = "INFO",
+                              .data_offset = 24,
+                              .data_length = 9,
+                          },
+                          RiffChunk{
+                              .offset = 24,
+                              .length = 10,
+                              .id = "ISFT",
+                              .type = "",
+                              .data_offset = 32,
+                              .data_length = 1,
+                          }));
+}
+
+class NoSizeReader : public riegeli::Reader {
+ public:
+  bool SupportsSize() override { return false; }
+  bool PullSlow(size_t min_length, size_t recommended_length) override {
+    return false;
+  }
+};
+
+TEST(RiffReaderTest, IterateOverRiffChunksRequiresSize) {
+  NoSizeReader reader;
+  EXPECT_THAT(
+      IterateOverRiffChunks(reader, [](const RiffChunk&) { return true; }),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("reader size cannot be determined")));
+}
+
+TEST(RiffReaderTest, OverflowChunkSizeNoInfiniteLoop) {
+  riegeli::StringWriter writer;
+  writer.Write("RIFF");
+  riegeli::WriteLittleEndian<uint32_t>(20, writer);
+  writer.Write("WAVE");
+  writer.Write("JUNK");
+  riegeli::WriteLittleEndian<uint32_t>(0xFFFFFFF8, writer);
+  writer.Close();
+  std::string contents = writer.dest();
+
+  riegeli::StringReader<> input(contents);
+  EXPECT_THAT(
+      IterateOverRiffChunks(input, [](const RiffChunk&) { return true; }),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("RIFF chunk extends beyond the end of the file")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/tiff/BUILD b/formats/tiff/BUILD
new file mode 100644
index 0000000..ddc69bc
--- /dev/null
+++ b/formats/tiff/BUILD
@@ -0,0 +1,139 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = ["@abseil-cpp//absl/strings:string_view"],
+)
+
+cc_library(
+    name = "reader",
+    srcs = ["reader.cc"],
+    hdrs = ["reader.h"],
+    deps = [
+        ":constants",
+        "//formats:byte_range",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "reader_test",
+    srcs = ["reader_test.cc"],
+    deps = [
+        ":constants",
+        ":reader",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":constants",
+        ":reader",
+        "//constants:labels",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    deps = [
+        ":constants",
+        ":extractor",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor_result",
+        "//utils:byte_writers",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        ":constants",
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/tiff/assessor.cc b/formats/tiff/assessor.cc
new file mode 100644
index 0000000..8fb1f1f
--- /dev/null
+++ b/formats/tiff/assessor.cc
@@ -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.
+//
+
+#include "formats/tiff/assessor.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/tiff/constants.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+
+absl::StatusOr<bool> TiffAssessor::IsSupported(riegeli::Reader& input) const {
+  int64_t starting_position = input.pos();
+
+  uint32_t leading_bytes;
+  if (!riegeli::ReadBigEndian<uint32_t>(input, leading_bytes)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; header"));
+  }
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; seek"));
+  }
+
+  return leading_bytes == kTiffLittleEndianHeader ||
+         leading_bytes == kTiffBigEndianHeader;
+}
+
+}  // namespace credentio
diff --git a/formats/tiff/assessor.h b/formats/tiff/assessor.h
new file mode 100644
index 0000000..cfd4fe4
--- /dev/null
+++ b/formats/tiff/assessor.h
@@ -0,0 +1,34 @@
+// 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_FORMATS_TIFF_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Assessor for the TIFF image format.
+class TiffAssessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_ASSESSOR_H_
diff --git a/formats/tiff/assessor_test.cc b/formats/tiff/assessor_test.cc
new file mode 100644
index 0000000..daef9c4
--- /dev/null
+++ b/formats/tiff/assessor_test.cc
@@ -0,0 +1,94 @@
+// 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 "formats/tiff/assessor.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(IsAssetSupportedTest, ErrorTooFewBytes) {
+  std::string image = "\xab";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(
+      TiffAssessor().IsSupported(input),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof")));
+}
+
+TEST(IsAssetSupportedTest, FalseForInvalidBeginning) {
+  std::string image = "this_is_not_a_riff";
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(false));
+}
+
+TEST(IsAssetSupportedTest, ValidStartingBigEndianBytes) {
+  std::vector<uint8_t> data = {0x4d, 0x4d, 0x00, 0x2a};
+  std::string image = std::string(data.begin(), data.end());
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsAssetSupportedTest, ValidStartingLittleEndianBytes) {
+  std::vector<uint8_t> data = {0x49, 0x49, 0x2a, 0x00};
+  std::string image = std::string(data.begin(), data.end());
+  riegeli::StringReader<> input(image);
+  EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(true));
+}
+
+TEST(IsAssetSupportedTest, ValidStartingBigEndianBytesAtOffset2) {
+  std::vector<uint8_t> data = {'a', 'b', 0x4d, 0x4d, 0x00, 0x2a};
+  std::string image = std::string(data.begin(), data.end());
+  riegeli::StringReader<> input(image);
+
+  // Invalid at 0
+  EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+TEST(IsAssetSupportedTest, ValidStartingLittleEndianBytesAtOffset2) {
+  std::vector<uint8_t> data = {'a', 'b', 0x49, 0x49, 0x2a, 0x00};
+  std::string image = std::string(data.begin(), data.end());
+  riegeli::StringReader<> input(image);
+
+  // Invalid at 0
+  EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(false));
+  EXPECT_EQ(input.pos(), 0);
+
+  // Valid at 2
+  ASSERT_TRUE(input.Seek(2));
+  EXPECT_THAT(TiffAssessor().IsSupported(input), IsOkAndHolds(true));
+  EXPECT_EQ(input.pos(), 2);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/tiff/constants.h b/formats/tiff/constants.h
new file mode 100644
index 0000000..91bcd9c
--- /dev/null
+++ b/formats/tiff/constants.h
@@ -0,0 +1,115 @@
+// 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_FORMATS_TIFF_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_CONSTANTS_H_
+
+#include <cstdint>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// The size of an IFD Entry: tag (2) + type (2) + count (4) + value/offset (4)
+constexpr int64_t kTiffIfdEntrySize = 12;
+
+// The size of an IFD: count (2) + next offset (4) + with a single entry.
+constexpr int64_t kTiffIdfOneEntrySize = 6 + kTiffIfdEntrySize;
+
+constexpr uint16_t kTiffBigEndian = 0x4d4d;
+constexpr uint16_t kTiffLittleEndian = 0x4949;
+
+constexpr uint16_t kTiffMarker = 0x002a;
+
+constexpr uint32_t kTiffBigEndianHeader = 0x4d4d002a;
+// This is for quick asset checking in BigEndian order if the asset is a valid
+// LittleEndian TIFF.
+constexpr uint32_t kTiffLittleEndianHeader = 0x49492a00;
+
+// This is the tag for the C2PA IFD Entry.
+constexpr uint16_t kTiffTagC2pa = 0xcd41;
+
+// These are the tag pairs that are used for offsets and byte counts.
+constexpr uint16_t kTiffTagStripOffsets = 0x0111;
+constexpr uint16_t kTiffTagStripByteCounts = 0x0117;
+constexpr uint16_t kTiffTagFreeOffsets = 0x0120;
+constexpr uint16_t kTiffTagFreeByteCounts = 0x0121;
+constexpr uint16_t kTiffTagTileOffsets = 0x0144;
+constexpr uint16_t kTiffTagTileByteCounts = 0x0145;
+
+// The hex codes for IFD Entry Tags which contain Sub-IFDs.
+constexpr uint16_t kTiffTagSubIfd = 0x014a;
+constexpr uint16_t kTiffTagExifIfd = 0x8769;
+constexpr uint16_t kTiffTagGpsInfo = 0x8825;
+constexpr uint16_t kTiffTagInteroptabilityIfd = 0xA005;
+
+// This is the decimal tag for the GPS Info Sub-IFD converted to a string.
+constexpr absl::string_view kTiffGpsInfoIdentifier = "34853";
+
+// The maximum valid tag ID in the GPS Info Sub-IFD according to EXIF 2.32.
+constexpr uint16_t kMaxGpsTag = 0x001f;
+
+// The hex codes for each of the IFD Entry Types.
+constexpr uint16_t kTiffTypeByte = 0x0001;       // uint8_t
+constexpr uint16_t kTiffTypeAscii = 0x0002;      // char
+constexpr uint16_t kTiffTypeShort = 0x0003;      // uint16_t
+constexpr uint16_t kTiffTypeLong = 0x0004;       // uint32_t
+constexpr uint16_t kTiffTypeRational = 0x0005;   // uint32_t/uint32_t
+constexpr uint16_t kTiffTypeSByte = 0x0006;      // int8_t
+constexpr uint16_t kTiffTypeUndefined = 0x0007;  // uint8_t (arbitrary data)
+constexpr uint16_t kTiffTypeSShort = 0x0008;     // int16_t
+constexpr uint16_t kTiffTypeSLong = 0x0009;      // int32_t
+constexpr uint16_t kTiffTypeSRational = 0x000a;  // int32_t/int32_t
+constexpr uint16_t kTiffTypeFloat = 0x000b;      // float (4-byte)
+constexpr uint16_t kTiffTypeDouble = 0x000c;     // double (8-byte)
+
+constexpr uint64_t SizeOfTiffType(uint16_t field_type) {
+  switch (field_type) {
+    case kTiffTypeByte:
+      return 1;
+    case kTiffTypeAscii:
+      return 1;
+    case kTiffTypeShort:
+      return 2;
+    case kTiffTypeLong:
+      return 4;
+    case kTiffTypeRational:
+      return 8;
+    case kTiffTypeSByte:
+      return 1;
+    case kTiffTypeUndefined:
+      return 1;
+    case kTiffTypeSShort:
+      return 2;
+    case kTiffTypeSLong:
+      return 4;
+    case kTiffTypeSRational:
+      return 8;
+    case kTiffTypeFloat:
+      return 4;
+    case kTiffTypeDouble:
+      return 8;
+    default:
+      return 1;
+  }
+}
+
+constexpr uint64_t ByteSizeOfImageFileDirectory(uint16_t entry_count) {
+  return sizeof(uint16_t) + entry_count * kTiffIfdEntrySize + sizeof(uint32_t);
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_CONSTANTS_H_
diff --git a/formats/tiff/extractor.cc b/formats/tiff/extractor.cc
new file mode 100644
index 0000000..89409f6
--- /dev/null
+++ b/formats/tiff/extractor.cc
@@ -0,0 +1,162 @@
+// 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.
+//
+
+// TIFF Format Information:
+//   https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf
+// C2PA embedding:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_tiff_based_assets
+#include "formats/tiff/extractor.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/byte_range.h"
+#include "formats/tiff/constants.h"
+#include "formats/tiff/reader.h"
+#include "jumbf/utils.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;  // 10 MiB
+
+absl::StatusOr<std::optional<ByteRange>> FindManifestStore(
+    riegeli::Reader& source) {
+  absl::flat_hash_set<uint32_t> visited_offsets;
+
+  std::optional<ByteRange> manifest_store_location = std::nullopt;
+  bool reading_first_ifd = true;
+  absl::Status iteration_status = IterateOverImageFileDirectories(
+      source, std::nullopt, visited_offsets,
+      [&reading_first_ifd, &manifest_store_location](
+          const TiffImageFileDirectory& ifd,
+          TiffEndianness endianness) -> absl::StatusOr<bool> {
+        // A previous loop found a manifest store.
+        if (manifest_store_location.has_value()) {
+          return absl::NotFoundError("Manifest Store must be in the last IFD");
+        }
+
+        for (const TiffImageFileDirectoryEntry& entry : ifd.entries) {
+          if (entry.tag != kTiffTagC2pa) {
+            continue;
+          }
+
+          if (!reading_first_ifd && ifd.entries.size() != 1) {
+            return absl::NotFoundError(
+                "Manifest Store must be the only entry in the IFD");
+          }
+
+          if (manifest_store_location.has_value()) {
+            return absl::NotFoundError("Multiple manifest stores found");
+          }
+
+          manifest_store_location = entry.RangeOfValue();
+        }
+
+        reading_first_ifd = false;
+        return true;
+      });
+  ABSL_RETURN_IF_ERROR(iteration_status);
+  return manifest_store_location;
+}
+
+bool IsPartialAsset(int64_t current_offset, int64_t end_offset,
+                    int64_t asset_size) {
+  if (end_offset == -1) {
+    // Not a windowed read, so not partial.
+    return false;
+  }
+  if (current_offset == 0 && end_offset == asset_size) {
+    // The window covers the entire asset, so it is not partial.
+    return false;
+  }
+  return true;
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> TiffExtractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  ABSL_ASSIGN_OR_RETURN(std::optional<ByteRange> location,
+                        ExtractManifestStoreLocation(input, {}));
+  if (!location.has_value()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+
+  if (location->length > kMaxPayloadSize) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Manifest store is too large: ", location->length, " > ",
+                     kMaxPayloadSize));
+  }
+
+  if (!input.Seek(location->offset)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+
+  std::string manifest_store;
+  if (!input.Read(location->length, manifest_store)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to read manifest store"));
+  }
+  return std::move(manifest_store);
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+TiffExtractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                            ExtractOptions options) const {
+  if (IsPartialAsset(input.pos(), options.end_offset,
+                     input.Size().value_or(0))) {
+    return absl::InvalidArgumentError(
+        "TiffExtractor::ExtractManifestStoreLocation only supports operations "
+        "over the entire file.");
+  }
+
+  if (!input.Seek(0)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to seek to offset"));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto result, FindManifestStore(input));
+  if (!options.requires_c2pa) {
+    return result;
+  }
+  if (!result.has_value()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return result;
+}
+
+bool TiffExtractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+
+}  // namespace credentio
diff --git a/formats/tiff/extractor.h b/formats/tiff/extractor.h
new file mode 100644
index 0000000..2e0134f
--- /dev/null
+++ b/formats/tiff/extractor.h
@@ -0,0 +1,63 @@
+// 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_FORMATS_TIFF_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Implements the C2PA Manifest Store extractor for TIFF files as described in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_tiff_based_assets
+class TiffExtractor : public FormatExtractor {
+ public:
+  TiffExtractor() = default;
+  ~TiffExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  // Given the heavy reliance on exact offsets built into this format, we can
+  // only support extraction methods that operate on the entire file (options:
+  // {.end_offset = -1}).
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  // Given the heavy reliance on exact offsets built into this format, we can
+  // only support extraction methods that operate on the entire file (options:
+  // {.end_offset = -1}).
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError(
+        "Extracting boxes is not supported for TIFF files.");
+  }
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_EXTRACTOR_H_
diff --git a/formats/tiff/extractor_test.cc b/formats/tiff/extractor_test.cc
new file mode 100644
index 0000000..6c580e8
--- /dev/null
+++ b/formats/tiff/extractor_test.cc
@@ -0,0 +1,786 @@
+// 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 "formats/tiff/extractor.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor_result.h"
+#include "formats/tiff/constants.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "utils/byte_writers.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+
+#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
+
+using ::testing::HasSubstr;
+
+struct TestCase {
+  std::string name;
+  absl::StatusOr<std::string> contents;
+  absl::StatusOr<ExtractorResult> result;
+};
+
+void WriteTiffHeader(uint32_t next_ifd_offset, std::vector<uint8_t>& data) {
+  WriteUint16NetworkOrder(kTiffBigEndian, &data);   // Endian marker
+  WriteUint16NetworkOrder(kTiffMarker, &data);      // TIFF marker
+  WriteUint32NetworkOrder(next_ifd_offset, &data);  // Next IFD offset
+}
+
+void WriteString(absl::string_view contents, std::vector<uint8_t>& data) {
+  for (int i = 0; i < contents.size(); ++i) {
+    data.push_back(contents[i]);
+  }
+}
+
+struct Ifde {
+  uint16_t tag;
+  uint16_t type;
+  uint32_t count;
+  uint32_t value;
+};
+
+void WriteIfd(std::vector<Ifde> entries, uint32_t next_ifd,
+              std::vector<uint8_t>& data) {
+  WriteUint16NetworkOrder(entries.size(), &data);  // IFD entry count
+  for (const Ifde& entry : entries) {
+    WriteUint16NetworkOrder(entry.tag, &data);    // Tag
+    WriteUint16NetworkOrder(entry.type, &data);   // Type
+    WriteUint32NetworkOrder(entry.count, &data);  //  Count
+    WriteUint32NetworkOrder(entry.value, &data);  // Value
+  }
+  WriteUint32NetworkOrder(next_ifd, &data);  // Next IFD offset
+}
+
+void WriteUint16(std::vector<uint16_t> entries, std::vector<uint8_t>& data) {
+  for (const uint16_t entry : entries) {
+    WriteUint16NetworkOrder(entry, &data);
+  }
+}
+
+void WriteUint32(std::vector<uint32_t> entries, std::vector<uint8_t>& data) {
+  for (const uint32_t entry : entries) {
+    WriteUint32NetworkOrder(entry, &data);
+  }
+}
+
+class ExtractorTest : public testing::TestWithParam<TestCase> {
+ public:
+  void SetUp() override { ASSERT_THAT(GetParam().contents.status(), IsOk()); }
+};
+
+INSTANTIATE_TEST_SUITE_P(
+    ExtractorTests, ExtractorTest,
+    testing::ValuesIn(
+        {TestCase{
+             .name = "EmptyContent",
+             .contents = "",
+             .result = absl::DataLossError("kUnexpectedEof"),
+         },
+         TestCase{
+             .name = "InvalidEndianMarker",
+             .contents = "\x12\x34",
+             .result = absl::DataLossError("endianness"),
+         },
+         TestCase{
+             .name = "InvalidTiffMarker",
+             .contents = "\x4d\x4d\x12\x34",
+             .result = absl::DataLossError("invalid endianness"),
+         },
+         TestCase{
+             .name = "SingleIfdNoEntries",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   WriteTiffHeader(8, data);
+                   WriteIfd({}, 0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = absl::NotFoundError("No manifest store found"),
+         },
+         TestCase{
+             .name = "SingleIfdNoC2paEntry",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   WriteTiffHeader(8, data);
+                   WriteIfd({{.tag = 0x1234,
+                              .type = 4,
+                              .count = 1,
+                              .value = 0x12345678}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = absl::NotFoundError("No manifest store found"),
+         },
+         TestCase{
+             .name = "ValidThreeValueOffsetTag",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(45, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 33) Offset Data
+                   WriteUint16({0x1234, 0x5678, 0x9abc}, data);
+                   // [33, 39) Offset Val
+                   WriteUint16({27, 29, 31}, data);
+                   // [39, 45) Counts Val
+                   WriteUint16({2, 2, 2}, data);
+                   // [45, 75) IFD
+                   WriteIfd(
+                       {{.tag = 0x0111, .type = 3, .count = 3, .value = 33},
+                        {.tag = 0x0117, .type = 3, .count = 3, .value = 39}},
+                       75, data);
+                   // [75, 93) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = ExtractorResult{.manifest_store = "test_manifest_store",
+                                       .asset_byte_info =
+                                           {.manifest_store_location =
+                                                {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "EntryWithMultipleUint8Values",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(27, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) First IFD
+                   WriteIfd({Ifde{.tag = 0x1234,
+                                  .type = 1,
+                                  .count = 3,
+                                  .value = 0x01020300}},
+                            45, data);
+                   // [45, 61) Second IFD
+                   WriteIfd({Ifde{.tag = kTiffTagC2pa,
+                                  .type = 0x0007,
+                                  .count = 19,
+                                  .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = ExtractorResult{.manifest_store = "test_manifest_store",
+                                       .asset_byte_info =
+                                           {.manifest_store_location =
+                                                {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "EntryWithMultipleUint16Values",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(27, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) First IFD
+                   WriteIfd({Ifde{.tag = 0x1234,
+                                  .type = 3,
+                                  .count = 2,
+                                  .value = 0x01020304}},
+                            45, data);
+                   // [45, 61) Second IFD
+                   WriteIfd({Ifde{.tag = kTiffTagC2pa,
+                                  .type = 0x0007,
+                                  .count = 19,
+                                  .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = ExtractorResult{.manifest_store = "test_manifest_store",
+                                       .asset_byte_info =
+                                           {.manifest_store_location =
+                                                {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "ValidSingleValueOffset",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(31, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 31) Offset Data
+                   WriteUint32({0x12345678}, data);
+                   // [31, 73) IFD
+                   WriteIfd(
+                       {
+                           {.tag = 0x0111, .type = 4, .count = 1, .value = 27},
+                           {.tag = 0x0115, .type = 4, .count = 1, .value = 4},
+                           {.tag = 0x0117, .type = 4, .count = 1, .value = 4},
+                       },
+                       73, data);
+                   // [73, 91) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = ExtractorResult{.manifest_store = "test_manifest_store",
+                                       .asset_byte_info =
+                                           {.manifest_store_location =
+                                                {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "ValidSingleValueOffsetWithExtraData",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(31, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 31) Offset Data
+                   WriteUint32({0x12345678}, data);
+                   // [31, 73) IFD
+                   WriteIfd(
+                       {
+                           {.tag = 0x0111, .type = 4, .count = 1, .value = 27},
+                           {.tag = 0x0115, .type = 4, .count = 1, .value = 4},
+                           {.tag = 0x0117, .type = 4, .count = 1, .value = 4},
+                       },
+                       73, data);
+                   // [73, 91) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   // [91, 101) Extra Data (not part of the IFD)
+                   return std::string(data.begin(), data.end()) + "extra_data";
+                 }(),
+             .result = ExtractorResult{.manifest_store = "test_manifest_store",
+                                       .asset_byte_info =
+                                           {.manifest_store_location =
+                                                {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "ValidSingleSubIfd",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(45, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) Sub IFD
+                   WriteIfd({{.tag = 0x1234,
+                              .type = 4,
+                              .count = 1,
+                              .value = 0x12345678}},
+                            0, data);
+                   // [45, 63) IFD
+                   WriteIfd(
+                       {{.tag = 0x014a, .type = 4, .count = 1, .value = 27}},
+                       63, data);
+                   // [63, 81) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = ExtractorResult{.manifest_store = "test_manifest_store",
+                                       .asset_byte_info =
+                                           {.manifest_store_location =
+                                                {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "ValidTwoLayerSubIfd",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(63, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) Child Sub IFD
+                   WriteIfd({{.tag = 0x1234,
+                              .type = 4,
+                              .count = 1,
+                              .value = 0x12345678}},
+                            0, data);
+                   // [45, 63) Parent Sub IFD
+                   WriteIfd(
+                       {{.tag = 0x014a, .type = 4, .count = 1, .value = 27}}, 0,
+                       data);
+                   // [63, 81) First Base IFD
+                   WriteIfd(
+                       {{.tag = 0x014a, .type = 4, .count = 1, .value = 45}},
+                       81, data);
+                   // [81, 99) Second Base IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = ExtractorResult{.manifest_store = "test_manifest_store",
+                                       .asset_byte_info =
+                                           {.manifest_store_location =
+                                                {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "ValidSubWithChainedIfd",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(63, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) Second Sub IFD
+                   WriteIfd({{.tag = 0x1234,
+                              .type = 4,
+                              .count = 1,
+                              .value = 0x12345678}},
+                            0, data);
+                   // [45, 63) First Sub IFD
+                   WriteIfd({{.tag = 0x1234,
+                              .type = 4,
+                              .count = 1,
+                              .value = 0x12345678}},
+                            27, data);
+                   // [63, 81) First Base IFD
+                   WriteIfd(
+                       {{.tag = 0x014a, .type = 4, .count = 1, .value = 45}},
+                       81, data);
+                   // [81, 99) Second Base IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = ExtractorResult{.manifest_store = "test_manifest_store",
+                                       .asset_byte_info =
+                                           {.manifest_store_location =
+                                                {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             // This is technically not a valid TIFF file, as there is no other
+             // data, but it abides by the C2PA spec.
+             .name = "SingleIfdOnlyC2paEntry",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(27, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result =
+                 ExtractorResult{
+                     .manifest_store = "test_manifest_store",
+                     .asset_byte_info = {.manifest_store_location =
+                                             {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "SingleIfdC2paEntryBeforeOtherEntry",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(27, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) IFD
+                   WriteIfd(
+                       {{.tag = kTiffTagC2pa,
+                         .type = kTiffTypeUndefined,
+                         .count = 19,
+                         .value = 8},
+                        {.tag = 0x1234, .type = 1, .count = 1, .value = 8}},
+                       0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result =
+                 ExtractorResult{
+                     .manifest_store = "test_manifest_store",
+                     .asset_byte_info = {.manifest_store_location =
+                                             {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "SingleIfdC2paEntryAfterOtherEntry",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(27, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) IFD
+                   WriteIfd({{.tag = 0x1234, .type = 1, .count = 1, .value = 8},
+                             {.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result =
+                 ExtractorResult{
+                     .manifest_store = "test_manifest_store",
+                     .asset_byte_info = {.manifest_store_location =
+                                             {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "MultipleIfdC2paInFirstIfd",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(27, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            45, data);
+                   // [45, 81) Second IFD
+                   WriteIfd(
+                       {{.tag = 0x1234, .type = 1, .count = 1, .value = 8}}, 0,
+                       data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result =
+                 absl::NotFoundError("Manifest Store must be in the last IFD"),
+         },
+         TestCase{
+             .name = "MultipleIfdC2paInSecondIfd",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(27, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) IFD
+                   WriteIfd(
+                       {{.tag = 0x1234, .type = 1, .count = 1, .value = 8}}, 45,
+                       data);
+                   // [45, 81) Second IFD
+                   WriteIfd({{.tag = 0x1234, .type = 1, .count = 1, .value = 8},
+                             {.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result = absl::NotFoundError(
+                 "Manifest Store must be the only entry in the IFD"),
+         },
+         TestCase{
+             .name = "UnknownIfdEntryType",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(27, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 45) IFD
+                   WriteIfd({{.tag = 0x1234,
+                              .type = 0x1234,
+                              .count = 1,
+                              .value = 8}},
+                            45, data);
+                   // [45, 81) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result =
+                 ExtractorResult{
+                     .manifest_store = "test_manifest_store",
+                     .asset_byte_info = {.manifest_store_location =
+                                             {.offset = 8, .length = 19}}},
+         },
+         TestCase{
+             .name = "MultipleIfdC2paInBoth",
+             .contents =
+                 []() {
+                   std::vector<uint8_t> data = {};
+                   // [0, 8) Tiff Header
+                   WriteTiffHeader(49, data);
+                   // [8, 27) Manifest Store
+                   WriteString("test_manifest_store", data);
+                   // [27, 49) Manifest Store
+                   WriteString("another_manifest_store", data);
+                   // [49, 67) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            67, data);
+                   // [67, 85) IFD
+                   WriteIfd({{.tag = kTiffTagC2pa,
+                              .type = kTiffTypeUndefined,
+                              .count = 19,
+                              .value = 8}},
+                            0, data);
+                   return std::string(data.begin(), data.end());
+                 }(),
+             .result =
+                 absl::NotFoundError("Manifest Store must be in the last IFD"),
+         },
+         TestCase{
+             .name = "FuzzTestRegression_b421332332",
+             .contents =
+                 std::string("MM\000*"
+                             "\000\000\000\t\000\000\001\001JJJJ\303\303"
+                             "\004\000\000\000\t\004\000\000\000",
+                             27),
+             .result = absl::InternalError("Failed to seek to offs"),
+         },
+         {
+             .name = "FuzzTestRegression_b422060716",
+             .contents = std::string(
+                 "MM\000*\000\000\000\t\000\000\002\001 "
+                 "\000\003\000\000\000\002\000\000\000\000\001!"
+                 "\000\004\000\000kkkkkkk\000..\000\000\000\303s\000\000",
+                 46),
+             .result = absl::InternalError("Failed to seek to offset"),
+         },
+         {
+             .name = "FuzzTestRegression_SmallC2paSize",
+             .contents = std::string(
+                 "II*"
+                 "\000e\000\000\000\014\001\232\001\014\000\000\356\000\000\224"
+                 "\000\000\000\001A\002\000l\205\000\0004\000\000\000\002\210"
+                 "\007\000\005\000@"
+                 "\304\214\000\270\000\000\300\n\377\305\324\200\377\377\000b"
+                 "\000\000\000\241\001\003\000pT@"
+                 "\225\257R\000\000J\001\004\000\001\003y\000\326\000\000\000"
+                 "\373\377\373\372\005\022\000\000\254\322\333\000\000\035\001"
+                 "\003\000\001\000A\315\007\000\004\000\000\000\362\001\000\000"
+                 "\000\000\000\000\t\343\337\000\2372\010\000\000\211\000\000"
+                 "\000\000\371\000\000\000r\000\000\200\340\005\000\017\004\000"
+                 "\000\034\010\000\000\000\027\000\230X\353\233\005\270\372=*"
+                 "\032\006\3768\332\034%"
+                 "\000\000\000\000N\310\000\000\332\357\213\213\020@\003 "
+                 "\000\242\027\000\001&"
+                 "\023\316\000E\000\000\000\000\000\002\250\001\001\345\226\000"
+                 "\000\000\000\000\200\000\000\000u\202)"
+                 "\030\327u\000\000\000\000\000\000\000\000\000\000\000\000\000"
+                 "\000\000\000\372\372\373\000\373PPPP\325\000\000\336@"
+                 "\000\000JJ\037\265\345S\000AA\003\332\376\000U\266\004\000"
+                 "\000\000\000\000\000\000\200\0148\007\000\000\000\000\000\000"
+                 "\377\377\177\375\255\351\205\003\000\000\273\001^"
+                 "\004E\201\034\211\371Iw\325\234\377\252\032S.\006n\344$"
+                 "\277\036\000\006;t)\324\307@)\332f\003\001\000[\000\246\307b "
+                 "l\177\010\000\000\000\000\257\354\334\244\243\243\243\243\243"
+                 "\243\243\243\332\332\332\243\247\243\243\243\243\243\243\243"
+                 "\243\243\243\243\243\243\243\243\243\243\315\243\243\243\243"
+                 "\243\243\243\243\243\243\243\247\250\247{"
+                 "\253\236\241\243\000\000\244\302,\230\230\230\230-"
+                 "1\002x\361xx\221\221\000\000\000\346\000m\2544\024\000\030"
+                 "\000\000\000\000\000\000\367\267AAAAAAAA\264\366|\304\264!"
+                 "\000\005\355\000\000\000\367\372\236#"
+                 "\374\000\230\300\300\300\300\300\300\300\300\300\300\300\300"
+                 "\300\300\300\2304\000\000\000",
+                 487),
+             .result =
+                 ExtractorResult{
+                     .manifest_store = std::string("\xF2\x01\x00\x00", 4),
+                     .asset_byte_info = {.manifest_store_location =
+                                             {.offset = 111, .length = 4}}},
+         }}),
+    [](const testing::TestParamInfo<ExtractorTest::ParamType>& info) {
+      return info.param.name;
+    });
+
+TEST_P(ExtractorTest, ExtractManifestStore) {
+  const TestCase& test_case = GetParam();
+  ASSERT_OK_AND_ASSIGN(std::string contents, test_case.contents);
+  riegeli::StringReader<> input(contents);
+  absl::StatusOr<std::string> result =
+      TiffExtractor().ExtractManifestStore(input);
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_THAT(*result, Eq(expected_result->manifest_store));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractManifestStoreLocation) {
+  const TestCase& test_case = GetParam();
+  ASSERT_OK_AND_ASSIGN(std::string contents, test_case.contents);
+  riegeli::StringReader<> input(contents);
+  absl::StatusOr<std::optional<ByteRange>> result =
+      TiffExtractor().ExtractManifestStoreLocation(input, {});
+
+  const auto& expected_result = test_case.result;
+  if (expected_result.ok()) {
+    ASSERT_THAT(result, IsOk());
+    EXPECT_THAT(*result,
+                Eq(expected_result->asset_byte_info.manifest_store_location));
+  } else {
+    EXPECT_THAT(result,
+                StatusIs(expected_result.status().code(),
+                         HasSubstr(expected_result.status().message())));
+  }
+}
+
+TEST_P(ExtractorTest, ExtractBoxes) {
+  ASSERT_OK_AND_ASSIGN(std::string contents, GetParam().contents);
+  riegeli::StringReader<> input(contents);
+  absl::StatusOr<std::vector<AssetBox>> result =
+      TiffExtractor().ExtractBoxes(input, {});
+
+  EXPECT_THAT(
+      result,
+      StatusIs(absl::StatusCode::kUnimplemented,
+               HasSubstr("Extracting boxes is not supported for TIFF files.")));
+}
+
+TEST(OffsetTiffExtractorTest, FailsWhenEndOffsetIsSmallerThanAsset) {
+  std::string contents = []() {
+    // Adding a prefix to the contents, need to offset all the offsets by 7.
+    std::vector<uint8_t> data = {};
+    // [0, 8) Tiff Header
+    WriteTiffHeader(27, data);
+    // [8, 27) Manifest Store
+    WriteString("test_manifest_store", data);
+    // [27, 45) IFD
+    WriteIfd({{.tag = kTiffTagC2pa,
+               .type = kTiffTypeUndefined,
+               .count = 19,
+               .value = 8}},
+             0, data);
+    return std::string(data.begin(), data.end());
+  }();
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      TiffExtractor().ExtractManifestStoreLocation(input, {.end_offset = 20}),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("TiffExtractor::ExtractManifestStoreLocation only "
+                         "supports operations over the entire file.")));
+}
+
+TEST(OffsetTiffExtractorTest,
+     FailsWhenEndOffsetEqualToAssetSizeAndCurrentOffsetIsNonZero) {
+  std::string contents = []() {
+    // Adding a prefix to the contents, need to offset all the offsets by 7.
+    std::vector<uint8_t> data = {};
+    // [0, 8) Tiff Header
+    WriteTiffHeader(27, data);
+    // [8, 27) Manifest Store
+    WriteString("test_manifest_store", data);
+    // [27, 45) IFD
+    WriteIfd({{.tag = kTiffTagC2pa,
+               .type = kTiffTypeUndefined,
+               .count = 19,
+               .value = 8}},
+             0, data);
+    return std::string(data.begin(), data.end());
+  }();
+  riegeli::StringReader<> input(contents);
+
+  ASSERT_TRUE(input.Seek(7));
+  EXPECT_THAT(
+      TiffExtractor().ExtractManifestStoreLocation(
+          input, {.end_offset = static_cast<int64_t>(contents.size())}),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("TiffExtractor::ExtractManifestStoreLocation only "
+                         "supports operations over the entire file.")));
+}
+
+TEST(TiffExtractorTest, UnboundedManifestStoreAllocation) {
+  std::string contents = []() {
+    std::vector<uint8_t> data = {};
+    WriteTiffHeader(13, data);
+    WriteString("short", data);
+    WriteIfd({{.tag = kTiffTagC2pa,
+               .type = kTiffTypeUndefined,
+               .count = 11 * 1024 * 1024,
+               .value = 8}},
+             0, data);
+    return std::string(data.begin(), data.end());
+  }();
+  riegeli::StringReader<> input(contents);
+  EXPECT_THAT(TiffExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Manifest store is too large")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/tiff/format.cc b/formats/tiff/format.cc
new file mode 100644
index 0000000..f6cef44
--- /dev/null
+++ b/formats/tiff/format.cc
@@ -0,0 +1,36 @@
+// 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 "formats/tiff/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+#include "formats/tiff/assessor.h"
+#include "formats/tiff/extractor.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateTiffFormat() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<TiffAssessor>(),
+      .extractor = std::make_unique<TiffExtractor>(),
+      .mime_types = {"image/x-adobe-dng", "image/tiff", "image/tiff-fx",
+                     "asset/octet-stream"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/tiff/format.h b/formats/tiff/format.h
new file mode 100644
index 0000000..98b7c97
--- /dev/null
+++ b/formats/tiff/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_TIFF_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateTiffFormat();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_FORMAT_H_
diff --git a/formats/tiff/reader.cc b/formats/tiff/reader.cc
new file mode 100644
index 0000000..89afe5e
--- /dev/null
+++ b/formats/tiff/reader.cc
@@ -0,0 +1,152 @@
+// 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 "formats/tiff/reader.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <optional>
+#include <vector>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "formats/byte_range.h"
+#include "formats/tiff/constants.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+namespace {
+template <typename T>
+absl::Status ReadNumber(riegeli::Reader& source, TiffEndianness endianness,
+                        T& value) {
+  bool success = endianness == TiffEndianness::kBigEndian
+                     ? riegeli::ReadBigEndian<T>(source, value)
+                     : riegeli::ReadLittleEndian<T>(source, value);
+  if (!success) {
+    return source.StatusOrAnnotate(absl::DataLossError("failed to read value"));
+  }
+  return absl::OkStatus();
+}
+
+absl::StatusOr<TiffEndianness> GetEndianness(riegeli::Reader& source) {
+  uint32_t header;
+  if (!riegeli::ReadBigEndian<uint32_t>(source, header)) {
+    return source.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; endianness"));
+  }
+  switch (header) {
+    case kTiffBigEndianHeader:
+      return TiffEndianness::kBigEndian;
+    case kTiffLittleEndianHeader:
+      return TiffEndianness::kLittleEndian;
+    default:
+      return absl::DataLossError("invalid endianness");
+  }
+}
+
+}  // namespace
+
+ByteRange TiffImageFileDirectoryEntry::RangeOfCount() const {
+  return ByteRange{.offset = offset + sizeof(uint16_t) + sizeof(uint16_t),
+                   .length = sizeof(uint32_t)};
+}
+
+ByteRange TiffImageFileDirectoryEntry::RangeOfValue() const {
+  uint64_t value_size = size();
+
+  return ByteRange{.offset = value_size > sizeof(uint32_t)
+                                 ? value_or_offset
+                                 : offset + sizeof(uint16_t) +
+                                       sizeof(uint16_t) + sizeof(uint32_t),
+                   .length = value_size};
+}
+
+absl::Status IterateOverImageFileDirectories(
+    riegeli::Reader& source, std::optional<TiffEndianness> known_endianness,
+    absl::flat_hash_set<uint32_t>& visited_offsets,
+    ImageFileDirectoryProcessor process_ifd) {
+  TiffEndianness endianness;
+  if (known_endianness.has_value()) {
+    endianness = *known_endianness;
+  } else {
+    ABSL_ASSIGN_OR_RETURN(endianness, GetEndianness(source));
+  }
+
+  while (true) {
+    TiffImageFileDirectory ifd{.offset_of_pointer =
+                                   static_cast<uint32_t>(source.pos())};
+
+    uint32_t offset;
+    ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, offset));
+    ifd.offset = offset;
+
+    if (!visited_offsets.insert(offset).second) {
+      return absl::InvalidArgumentError("infinite loop detected");
+    }
+
+    if (!source.Seek(offset)) {
+      return source.StatusOrAnnotate(absl::InternalError(
+          absl::StrCat("Failed to seek to offset: ", offset)));
+    }
+
+    uint16_t count;
+    ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, count));
+    ifd.entries.resize(count);
+    for (int i = 0; i < count; ++i) {
+      TiffImageFileDirectoryEntry& entry = ifd.entries[i];
+      entry.offset = source.pos();
+      ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, entry.tag));
+      ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, entry.type));
+      ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, entry.count));
+      ABSL_RETURN_IF_ERROR(
+          ReadNumber(source, endianness, entry.value_or_offset));
+    }
+
+    uint64_t next_offset_position = source.pos();
+
+    ABSL_ASSIGN_OR_RETURN(bool continue_processing,
+                          process_ifd(ifd, endianness));
+
+    if (!continue_processing) {
+      break;
+    }
+
+    if (!source.Seek(next_offset_position)) {
+      return source.StatusOrAnnotate(absl::InternalError(
+          absl::StrCat("Failed to seek to offset: ", next_offset_position)));
+    }
+
+    uint32_t next_offset;
+    ABSL_RETURN_IF_ERROR(ReadNumber(source, endianness, next_offset));
+
+    if (!source.Seek(next_offset_position)) {
+      return source.StatusOrAnnotate(absl::InternalError(
+          absl::StrCat("Failed to seek to offset: ", next_offset_position)));
+    }
+
+    if (next_offset == 0) {
+      break;
+    }
+  }
+
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/formats/tiff/reader.h b/formats/tiff/reader.h
new file mode 100644
index 0000000..602e044
--- /dev/null
+++ b/formats/tiff/reader.h
@@ -0,0 +1,73 @@
+// 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_FORMATS_TIFF_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_READER_H_
+
+#include <stdbool.h>
+#include <sys/types.h>
+
+#include <cstdint>
+#include <optional>
+#include <vector>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/functional/function_ref.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/byte_range.h"
+#include "formats/tiff/constants.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+enum class TiffEndianness {
+  kBigEndian,
+  kLittleEndian,
+};
+
+struct TiffImageFileDirectoryEntry {
+  uint64_t offset;
+
+  uint16_t tag;
+  uint16_t type;
+  uint32_t count;
+  uint32_t value_or_offset;
+
+  uint64_t size() const { return SizeOfTiffType(type) * count; }
+
+  ByteRange RangeOfCount() const;
+
+  ByteRange RangeOfValue() const;
+};
+
+struct TiffImageFileDirectory {
+  uint32_t offset_of_pointer;
+
+  uint32_t offset;
+  std::vector<TiffImageFileDirectoryEntry> entries;
+};
+
+using ImageFileDirectoryProcessor = absl::FunctionRef<absl::StatusOr<bool>(
+    const TiffImageFileDirectory&, TiffEndianness)>;
+
+absl::Status IterateOverImageFileDirectories(
+    riegeli::Reader& source, std::optional<TiffEndianness> known_endianness,
+    absl::flat_hash_set<uint32_t>& visited_offsets,
+    ImageFileDirectoryProcessor process_ifd);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_READER_H_
diff --git a/formats/tiff/reader_test.cc b/formats/tiff/reader_test.cc
new file mode 100644
index 0000000..4d5f3aa
--- /dev/null
+++ b/formats/tiff/reader_test.cc
@@ -0,0 +1,219 @@
+// 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 "formats/tiff/reader.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "formats/tiff/constants.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(TiffImageFileDirectoryEntryTest, RangeOfCount) {
+  TiffImageFileDirectoryEntry entry{.offset = 100};
+  // tag (2) + type (2) = 4
+  EXPECT_EQ(entry.RangeOfCount().offset, 104);
+  EXPECT_EQ(entry.RangeOfCount().length, 4);
+}
+
+TEST(TiffImageFileDirectoryEntryTest, RangeOfValueInline) {
+  TiffImageFileDirectoryEntry entry{
+      .offset = 100, .type = kTiffTypeShort, .count = 1, .value_or_offset = 42};
+  // Inline value is at offset + 2 (tag) + 2 (type) + 4 (count) = 108
+  EXPECT_EQ(entry.RangeOfValue().offset, 108);
+  EXPECT_EQ(entry.RangeOfValue().length, 2);
+}
+
+TEST(TiffImageFileDirectoryEntryTest, RangeOfValueAtOffset) {
+  TiffImageFileDirectoryEntry entry{
+      .offset = 100, .type = kTiffTypeLong, .count = 2, .value_or_offset = 500};
+  // 2 samples of Long (4 bytes each) = 8 bytes. > 4 bytes, so it's at an
+  // offset.
+  EXPECT_EQ(entry.RangeOfValue().offset, 500);
+  EXPECT_EQ(entry.RangeOfValue().length, 8);
+}
+
+TEST(IterateOverImageFileDirectoriesTest, SingleIfd) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+  riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer);
+  riegeli::WriteBigEndian<uint32_t>(8, writer);       // Offset to first IFD
+  riegeli::WriteBigEndian<uint16_t>(1, writer);       // count
+  riegeli::WriteBigEndian<uint16_t>(0x0100, writer);  // tag
+  riegeli::WriteBigEndian<uint16_t>(3, writer);       // type
+  riegeli::WriteBigEndian<uint32_t>(1, writer);       // count
+  riegeli::WriteBigEndian<uint32_t>(10, writer);      // value
+  riegeli::WriteBigEndian<uint32_t>(0, writer);       // next IFD
+  writer.Close();
+
+  riegeli::StringReader reader(buffer);
+  absl::flat_hash_set<uint32_t> visited_offsets;
+  int ifd_count = 0;
+  EXPECT_THAT(
+      IterateOverImageFileDirectories(
+          reader, /*known_endianness=*/std::nullopt, visited_offsets,
+          [&](const TiffImageFileDirectory& ifd, TiffEndianness endianness) {
+            ifd_count++;
+            EXPECT_EQ(ifd.entries.size(), 1);
+            EXPECT_EQ(ifd.entries[0].tag, 0x0100);
+            return true;
+          }),
+      IsOk());
+  EXPECT_EQ(ifd_count, 1);
+}
+
+TEST(IterateOverImageFileDirectoriesTest, StopEarly) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+  riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer);
+  riegeli::WriteBigEndian<uint32_t>(8, writer);  // Offset to first IFD
+
+  // First IFD (starts at 8):
+  riegeli::WriteBigEndian<uint16_t>(1, writer);       // count
+  riegeli::WriteBigEndian<uint16_t>(0x0100, writer);  // tag
+  riegeli::WriteBigEndian<uint16_t>(3, writer);       // type
+  riegeli::WriteBigEndian<uint32_t>(1, writer);       // count
+  riegeli::WriteBigEndian<uint32_t>(10, writer);      // value
+  riegeli::WriteBigEndian<uint32_t>(26,
+                                    writer);  // next IFD (8 + 2 + 12 + 4 = 26)
+
+  // Second IFD (starts at 26):
+  riegeli::WriteBigEndian<uint16_t>(1, writer);       // count
+  riegeli::WriteBigEndian<uint16_t>(0x0101, writer);  // tag
+  riegeli::WriteBigEndian<uint16_t>(3, writer);       // type
+  riegeli::WriteBigEndian<uint32_t>(1, writer);       // count
+  riegeli::WriteBigEndian<uint32_t>(20, writer);      // value
+  riegeli::WriteBigEndian<uint32_t>(0, writer);       // next IFD
+
+  writer.Close();
+
+  riegeli::StringReader reader(buffer);
+  absl::flat_hash_set<uint32_t> visited_offsets;
+  int ifd_count = 0;
+  EXPECT_THAT(IterateOverImageFileDirectories(
+                  reader, /*known_endianness=*/std::nullopt, visited_offsets,
+                  [&](const TiffImageFileDirectory& ifd,
+                      TiffEndianness endianness) -> absl::StatusOr<bool> {
+                    ifd_count++;
+                    EXPECT_EQ(ifd.entries.size(), 1);
+                    EXPECT_EQ(ifd.entries[0].tag, 0x0100);
+                    return false;  // Stop early!
+                  }),
+              IsOk());
+  EXPECT_EQ(ifd_count, 1);
+}
+
+TEST(IterateOverImageFileDirectoriesTest, InfiniteLoop) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+  riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer);
+  riegeli::WriteBigEndian<uint32_t>(8, writer);  // Offset to first IFD
+  riegeli::WriteBigEndian<uint16_t>(0, writer);  // count
+  riegeli::WriteBigEndian<uint32_t>(8, writer);  // Points back to self
+  writer.Close();
+
+  riegeli::StringReader reader(buffer);
+  absl::flat_hash_set<uint32_t> visited_offsets;
+  EXPECT_THAT(IterateOverImageFileDirectories(
+                  reader, /*known_endianness=*/std::nullopt, visited_offsets,
+                  [](const auto&, auto) { return true; }),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("infinite loop detected")));
+}
+
+TEST(IterateOverImageFileDirectoriesTest, KnownEndianness) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+  // No header, straight to IFD offset
+  riegeli::WriteBigEndian<uint32_t>(4, writer);       // Offset to first IFD
+  riegeli::WriteBigEndian<uint16_t>(1, writer);       // count
+  riegeli::WriteBigEndian<uint16_t>(0x0100, writer);  // tag
+  riegeli::WriteBigEndian<uint16_t>(3, writer);       // type
+  riegeli::WriteBigEndian<uint32_t>(1, writer);       // count
+  riegeli::WriteBigEndian<uint32_t>(10, writer);      // value
+  riegeli::WriteBigEndian<uint32_t>(0, writer);       // next IFD
+  writer.Close();
+
+  riegeli::StringReader reader(buffer);
+  absl::flat_hash_set<uint32_t> visited_offsets;
+  int ifd_count = 0;
+  EXPECT_THAT(
+      IterateOverImageFileDirectories(
+          reader, TiffEndianness::kBigEndian, visited_offsets,
+          [&](const TiffImageFileDirectory& ifd, TiffEndianness endianness) {
+            ifd_count++;
+            EXPECT_EQ(endianness, TiffEndianness::kBigEndian);
+            EXPECT_EQ(ifd.entries.size(), 1);
+            EXPECT_EQ(ifd.entries[0].tag, 0x0100);
+            return true;
+          }),
+      IsOk());
+  EXPECT_EQ(ifd_count, 1);
+}
+
+TEST(IterateOverImageFileDirectoriesTest, MissingFirstIfdOffset) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+  riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer);
+  // Missing the offset!
+  writer.Close();
+
+  riegeli::StringReader reader(buffer);
+  absl::flat_hash_set<uint32_t> visited_offsets;
+  EXPECT_THAT(
+      IterateOverImageFileDirectories(reader, /*known_endianness=*/std::nullopt,
+                                      visited_offsets,
+                                      [](const auto&, auto) { return true; }),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("failed to read value")));
+}
+
+TEST(IterateOverImageFileDirectoriesTest, MissingIfdCount) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+  riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer);
+  riegeli::WriteBigEndian<uint32_t>(8, writer);  // Offset to first IFD
+  // File ends here, size is 8.
+  writer.Close();
+
+  riegeli::StringReader reader(buffer);
+  absl::flat_hash_set<uint32_t> visited_offsets;
+  EXPECT_THAT(
+      IterateOverImageFileDirectories(reader, /*known_endianness=*/std::nullopt,
+                                      visited_offsets,
+                                      [](const auto&, auto) { return true; }),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("failed to read value")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/BUILD b/formats/zip/BUILD
new file mode 100644
index 0000000..10b9ff8
--- /dev/null
+++ b/formats/zip/BUILD
@@ -0,0 +1,151 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "reader",
+    srcs = ["reader.cc"],
+    hdrs = ["reader.h"],
+    deps = [
+        ":constants",
+        "//formats:byte_range",
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/memory",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/base:types",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "reader_test",
+    srcs = ["reader_test.cc"],
+    data = [
+        "//formats/zip/testing:assets",
+    ],
+    deps = [
+        ":reader",
+        "//formats:byte_range",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/base:types",
+        "@riegeli//riegeli/bytes:cfile_reader",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "extractor",
+    srcs = ["extractor.cc"],
+    hdrs = ["extractor.h"],
+    deps = [
+        ":constants",
+        ":reader",
+        "//constants:labels",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats:extractor",
+        "//jumbf:utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/base:types",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "extractor_test",
+    srcs = ["extractor_test.cc"],
+    data = [
+        "//formats/zip/testing:assets",
+    ],
+    deps = [
+        ":constants",
+        ":extractor",
+        "//formats:byte_range",
+        "//testing:jumbf_utils",
+        "//testing:test_file_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/types:span",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "assessor",
+    srcs = ["assessor.cc"],
+    hdrs = ["assessor.h"],
+    deps = [
+        ":constants",
+        "//formats:assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "assessor_test",
+    srcs = ["assessor_test.cc"],
+    deps = [
+        ":assessor",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "format",
+    srcs = ["format.cc"],
+    hdrs = ["format.h"],
+    deps = [
+        ":assessor",
+        ":extractor",
+        "//formats:format",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
diff --git a/formats/zip/assessor.cc b/formats/zip/assessor.cc
new file mode 100644
index 0000000..c6c7ed6
--- /dev/null
+++ b/formats/zip/assessor.cc
@@ -0,0 +1,43 @@
+// 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 "formats/zip/assessor.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/zip/constants.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+absl::StatusOr<bool> ZipAssessor::IsSupported(riegeli::Reader& input) const {
+  int64_t starting_position = input.pos();
+
+  std::string file_header;
+  if (!input.Read(kZipLocalFileHeaderSignature.size(), file_header)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("kUnexpectedEof; header"));
+  }
+  if (!input.Seek(starting_position) || input.pos() != starting_position) {
+    return input.StatusOrAnnotate(absl::DataLossError("kUnexpectedEof; seek"));
+  }
+
+  return file_header == kZipLocalFileHeaderSignature;
+}
+
+}  // namespace credentio
diff --git a/formats/zip/assessor.h b/formats/zip/assessor.h
new file mode 100644
index 0000000..c52aa5c
--- /dev/null
+++ b/formats/zip/assessor.h
@@ -0,0 +1,32 @@
+// 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_FORMATS_ZIP_ASSESSOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ASSESSOR_H_
+
+#include "absl/status/statusor.h"
+#include "formats/assessor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+class ZipAssessor : public FormatAssessor {
+ public:
+  // Returns true if the supplied asset is supported.
+  absl::StatusOr<bool> IsSupported(riegeli::Reader& input) const override;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ASSESSOR_H_
diff --git a/formats/zip/assessor_test.cc b/formats/zip/assessor_test.cc
new file mode 100644
index 0000000..8455e77
--- /dev/null
+++ b/formats/zip/assessor_test.cc
@@ -0,0 +1,68 @@
+// 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 "formats/zip/assessor.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(IsSupportedTest, ErrorTooFewBytes) {
+  std::string zip = "P";
+  riegeli::StringReader<> reader(zip);
+
+  EXPECT_THAT(
+      ZipAssessor().IsSupported(reader),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("kUnexpectedEof")));
+}
+
+TEST(IsSupportedTest, FalseForInvalidBeginning) {
+  std::string zip = "this_is_not_a_zip";
+  riegeli::StringReader<> reader(zip);
+
+  EXPECT_THAT(ZipAssessor().IsSupported(reader), IsOkAndHolds(false));
+}
+
+TEST(IsSupportedTest, ValidStartingBytes) {
+  std::string zip = "PK\x03\x04\xab\xcd";
+  riegeli::StringReader<> reader(zip);
+
+  EXPECT_THAT(ZipAssessor().IsSupported(reader), IsOkAndHolds(true));
+}
+
+TEST(IsSupportedTest, ValidStartingBytesAtOffset2) {
+  std::string zip = "\xab\xcdPK\x03\x04";
+  riegeli::StringReader<> reader(zip);
+
+  // Invalid at offset 0
+  EXPECT_THAT(ZipAssessor().IsSupported(reader), IsOkAndHolds(false));
+  ASSERT_TRUE(reader.Seek(2));
+  // Valid at offset 2
+  EXPECT_THAT(ZipAssessor().IsSupported(reader), IsOkAndHolds(true));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/constants.h b/formats/zip/constants.h
new file mode 100644
index 0000000..055621b
--- /dev/null
+++ b/formats/zip/constants.h
@@ -0,0 +1,50 @@
+// 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_FORMATS_ZIP_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONSTANTS_H_
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+constexpr absl::string_view kZipCentralDirectorySignature = "PK\x01\x02";
+constexpr absl::string_view kZipLocalFileHeaderSignature = "PK\x03\x04";
+constexpr absl::string_view kZipEndOfCentralDirectorySignature = "PK\x05\x06";
+constexpr absl::string_view kZipZip64EndOfCentralDirectorySignature =
+    "PK\x06\x07";
+constexpr absl::string_view kZipDataDescriptorSignature = "PK\x07\x08";
+
+// Size of the data descriptor, with or without the optional signature.
+constexpr int kDataDescriptorWithSignatureSize = 16;
+constexpr int kDataDescriptorSize = 12;
+// Size of the fixed portion of the local file header, excluding the file name
+// and extra fields.
+constexpr int kLocalFileHeaderFixedSize = 30;
+// Size of the fixed portion of the central directory header, excluding the file
+// name, extra field and file comment.
+constexpr int kCentralDirectoryHeaderFixedSize = 46;
+// Size of the End of Central Directory (EOCD) record.
+// ZIP files based on ISO/IEC 29500:2 (OOXML) do not allow a comment field in
+// the EOCD record, making it a fixed size.
+constexpr int kEocdRecordSize = 22;
+
+constexpr absl::string_view kZipManifestFileName =
+    "META-INF/content_credential.c2pa";
+constexpr absl::string_view kZipC2paManifestMimeType = "application/c2pa";
+constexpr absl::string_view kOoxmlContentTypesFileName = "[Content_Types].xml";
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONSTANTS_H_
diff --git a/formats/zip/extractor.cc b/formats/zip/extractor.cc
new file mode 100644
index 0000000..a739332
--- /dev/null
+++ b/formats/zip/extractor.cc
@@ -0,0 +1,118 @@
+// 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 "formats/zip/extractor.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/byte_range.h"
+#include "formats/zip/constants.h"
+#include "formats/zip/reader.h"
+#include "jumbf/utils.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+struct C2paManifestStore {
+  ByteRange location;
+  std::string content;
+};
+
+absl::StatusOr<C2paManifestStore> ExtractManifestStoreInternal(
+    riegeli::Reader& input) {
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ZipReader> reader,
+                        ZipReader::Create(&input));
+
+  C2paManifestStore manifest_store;
+  while (reader->HasNext()) {
+    ABSL_ASSIGN_OR_RETURN(ZipReader::FileEntry entry, reader->Next());
+    if (entry.file_name == kZipManifestFileName) {
+      if (!manifest_store.content.empty()) {
+        return absl::NotFoundError("Multiple manifest stores found");
+      }
+      if (entry.compression_method != 0) {
+        return absl::InvalidArgumentError(
+            "Manifest store must not be compressed.");
+      }
+      manifest_store.location = entry.file_range;
+      // Manifest store is typically up to a few MBs (usually under 16 MB).
+      constexpr uint64_t kMaxManifestStoreSize = 16 * 1024 * 1024;
+      if (manifest_store.location.length > kMaxManifestStoreSize ||
+          entry.uncompressed_size > kMaxManifestStoreSize) {
+        return absl::InvalidArgumentError(
+            "Manifest store size exceeds maximum limit.");
+      }
+      std::optional<riegeli::Position> source_size = input.Size();
+      if (source_size.has_value() &&
+          manifest_store.location.length > *source_size) {
+        return absl::InvalidArgumentError(
+            "Manifest store length exceeds source size.");
+      }
+      if (!input.Seek(entry.file_range.offset) ||
+          input.pos() != entry.file_range.offset) {
+        return absl::InvalidArgumentError(
+            "The input could not be reset to the manifest store location.");
+      }
+      if (!input.Read(manifest_store.location.length, manifest_store.content)) {
+        return input.StatusOrAnnotate(
+            absl::InternalError("Failed to read manifest store"));
+      }
+    }
+  }
+  if (manifest_store.content.empty()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+  return manifest_store;
+}
+}  // namespace
+
+absl::StatusOr<std::string> ZipExtractor::ExtractManifestStore(
+    riegeli::Reader& input) const {
+  ABSL_ASSIGN_OR_RETURN(auto manifest_store,
+                        ExtractManifestStoreInternal(input));
+  return manifest_store.content;
+}
+
+absl::StatusOr<std::optional<ByteRange>>
+ZipExtractor::ExtractManifestStoreLocation(riegeli::Reader& input,
+                                           ExtractOptions options) const {
+  auto manifest_store = ExtractManifestStoreInternal(input);
+  if (!manifest_store.ok()) {
+    if (options.requires_c2pa) {
+      return manifest_store.status();
+    }
+    return std::nullopt;
+  }
+  return manifest_store->location;
+}
+
+bool ZipExtractor::MightBeC2paManifestStore(absl::string_view payload) const {
+  return jumbf::HasDescriptionBoxMatching(payload, kManifestStoreUuid,
+                                          kMinimumJumbfDescriptionToggles,
+                                          kManifestStoreLabel)
+      .value_or(false);
+}
+
+}  // namespace credentio
diff --git a/formats/zip/extractor.h b/formats/zip/extractor.h
new file mode 100644
index 0000000..6d90996
--- /dev/null
+++ b/formats/zip/extractor.h
@@ -0,0 +1,57 @@
+// 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_FORMATS_ZIP_EXTRACTOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EXTRACTOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/extractor.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Implements the C2PA Manifest Store extractor for ZIP-based assets as
+// described in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_embedding_manifests_into_zip_based_formats
+class ZipExtractor : public FormatExtractor {
+ public:
+  ZipExtractor() = default;
+  ~ZipExtractor() override = default;
+
+  absl::StatusOr<std::string> ExtractManifestStore(
+      riegeli::Reader& input) const override;
+
+  absl::StatusOr<std::optional<ByteRange>> ExtractManifestStoreLocation(
+      riegeli::Reader& input, ExtractOptions options) const override;
+
+  absl::StatusOr<std::vector<AssetBox>> ExtractBoxes(
+      riegeli::Reader& input, ExtractOptions options) const override {
+    return absl::UnimplementedError("ZIP does not have a concept of boxes");
+  };
+
+  bool MightBeC2paManifestStore(absl::string_view payload) const override;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EXTRACTOR_H_
diff --git a/formats/zip/extractor_test.cc b/formats/zip/extractor_test.cc
new file mode 100644
index 0000000..a2f6021
--- /dev/null
+++ b/formats/zip/extractor_test.cc
@@ -0,0 +1,418 @@
+// 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 "formats/zip/extractor.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <cstdlib>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "formats/byte_range.h"
+#include "formats/zip/constants.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/endian/endian_writing.h"
+#include "testing/jumbf_utils.h"
+#include "testing/test_file_utils.h"
+
+// Compatibility macros for OSS
+#ifndef ASSERT_OK
+#define ASSERT_OK(expr) ASSERT_THAT(expr, ::absl_testing::IsOk())
+#endif
+
+#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
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::AllOf;
+using ::testing::Eq;
+using ::testing::Field;
+using ::testing::HasSubstr;
+
+// A Zip file entry.
+struct ZipEntry {
+  std::string name;
+  std::string content;
+};
+
+// Creates a minimal Zip file in memory.
+// This is not a full-fledged Zip creator, but enough to satisfy ZipReader.
+absl::StatusOr<std::string> CreateFakeZip(absl::Span<const ZipEntry> entries) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+
+  // Write local file headers and data.
+  std::vector<uint32_t> offsets;
+  for (const auto& entry : entries) {
+    offsets.push_back(writer.pos());
+
+    writer.Write(kZipLocalFileHeaderSignature);
+    // Version needed to extract (2.0)
+    riegeli::WriteLittleEndian<uint16_t>(20, writer);
+    // General purpose bit flag
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // Compression method (stored)
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // Last mod file time
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // Last mod file date
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // CRC-32 (0 for now, we don't check it)
+    riegeli::WriteLittleEndian<uint32_t>(0, writer);
+    // Compressed size
+    riegeli::WriteLittleEndian<uint32_t>(entry.content.size(), writer);
+    // Uncompressed size
+    riegeli::WriteLittleEndian<uint32_t>(entry.content.size(), writer);
+    // File name length
+    riegeli::WriteLittleEndian<uint16_t>(entry.name.size(), writer);
+    // Extra field length
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+
+    // File name
+    writer.Write(entry.name);
+    // File data
+    writer.Write(entry.content);
+  }
+
+  uint32_t central_directory_start_offset = writer.pos();
+
+  // Write central directory.
+  for (size_t i = 0; i < entries.size(); ++i) {
+    const auto& entry = entries[i];
+    writer.Write(kZipCentralDirectorySignature);
+    // Version made by
+    riegeli::WriteLittleEndian<uint16_t>(20, writer);
+    // Version needed to extract
+    riegeli::WriteLittleEndian<uint16_t>(20, writer);
+    // General purpose bit flag
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // Compression method
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // Last mod file time
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // Last mod file date
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // CRC-32
+    riegeli::WriteLittleEndian<uint32_t>(0, writer);
+    // Compressed size
+    riegeli::WriteLittleEndian<uint32_t>(entry.content.size(), writer);
+    // Uncompressed size
+    riegeli::WriteLittleEndian<uint32_t>(entry.content.size(), writer);
+    // File name length
+    riegeli::WriteLittleEndian<uint16_t>(entry.name.size(), writer);
+    // Extra field length
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // File comment length
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // Disk number start
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // Internal file attributes
+    riegeli::WriteLittleEndian<uint16_t>(0, writer);
+    // External file attributes
+    riegeli::WriteLittleEndian<uint32_t>(0, writer);
+    // Relative offset of local header
+    riegeli::WriteLittleEndian<uint32_t>(offsets[i], writer);
+
+    // File name
+    writer.Write(entry.name);
+  }
+
+  uint32_t central_directory_size =
+      writer.pos() - central_directory_start_offset;
+
+  // Write End of Central Directory (EOCD) record.
+  writer.Write(kZipEndOfCentralDirectorySignature);
+  // Number of this disk
+  riegeli::WriteLittleEndian<uint16_t>(0, writer);
+  // Number of the disk with the start of the central directory
+  riegeli::WriteLittleEndian<uint16_t>(0, writer);
+  // Total number of entries in the central directory on this disk
+  riegeli::WriteLittleEndian<uint16_t>(entries.size(), writer);
+  // Total number of entries in the central directory
+  riegeli::WriteLittleEndian<uint16_t>(entries.size(), writer);
+  // Size of the central directory
+  riegeli::WriteLittleEndian<uint32_t>(central_directory_size, writer);
+  // Offset of start of central directory with respect to the starting disk
+  // number
+  riegeli::WriteLittleEndian<uint32_t>(central_directory_start_offset, writer);
+  // .ZIP file comment length
+  riegeli::WriteLittleEndian<uint16_t>(0, writer);
+
+  if (!writer.Close()) {
+    return writer.status();
+  }
+  return buffer;
+}
+
+TEST(ZipExtractorTest, ExtractManifestStore) {
+  ASSERT_OK_AND_ASSIGN(auto input,
+                       credentio_testing::GetFileReader(
+                           "c2pa/formats/zip/testing/asset_fake_c2pa.zip"));
+
+  EXPECT_THAT(ZipExtractor().ExtractManifestStore(*input),
+              IsOkAndHolds(HasSubstr("c2pa manifest")));
+}
+
+TEST(ZipExtractorTest,
+     ExtractManifestStoreThrowsErrorIfMultipleC2paManifestStoresFound) {
+  // Create a fake Zip file with duplicate manifest stores.
+  ASSERT_OK_AND_ASSIGN(auto zip_file_contents,
+                       CreateFakeZip({
+                           {.name = "META-INF/content_credential.c2pa",
+                            .content = "c2pa manifest 1"},
+                           {.name = "META-INF/content_credential.c2pa",
+                            .content = "c2pa manifest 2"},
+                       }));
+  riegeli::StringReader<> input(zip_file_contents);
+
+  EXPECT_THAT(ZipExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("Multiple manifest stores found")));
+}
+
+TEST(ZipExtractorTest, ExtractManifestStoreThrowsNotFoundErrorIfNoC2pa) {
+  ASSERT_OK_AND_ASSIGN(auto input,
+                       credentio_testing::GetFileReader(
+                           "c2pa/formats/zip/testing/asset_no_c2pa.zip"));
+
+  EXPECT_THAT(ZipExtractor().ExtractManifestStore(*input),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No manifest store found")));
+}
+
+TEST(ZipExtractorTest, ExtractManifestStoreLocation) {
+  ASSERT_OK_AND_ASSIGN(auto input,
+                       credentio_testing::GetFileReader(
+                           "c2pa/formats/zip/testing/asset_fake_c2pa.zip"));
+  ASSERT_OK_AND_ASSIGN(auto manifest_store_location,
+                       ZipExtractor().ExtractManifestStoreLocation(
+                           *input, {.requires_c2pa = true}));
+
+  EXPECT_THAT(manifest_store_location,
+              Optional(AllOf(Field(&ByteRange::offset, Eq(201)),
+                             Field(&ByteRange::length, Eq(14)))));
+}
+
+TEST(ZipExtractorTest,
+     ExtractManifestStoreLocationReturnsNulloptIfNoC2paAndNotRequired) {
+  ASSERT_OK_AND_ASSIGN(auto input,
+                       credentio_testing::GetFileReader(
+                           "c2pa/formats/zip/testing/asset_no_c2pa.zip"));
+  ASSERT_OK_AND_ASSIGN(auto manifest_store_location,
+                       ZipExtractor().ExtractManifestStoreLocation(
+                           *input, {.requires_c2pa = false}));
+
+  EXPECT_EQ(manifest_store_location, std::nullopt);
+}
+
+TEST(ZipExtractorTest,
+     ExtractManifestStoreLocationReturnsErrorIfNoC2paButRequired) {
+  ASSERT_OK_AND_ASSIGN(auto input,
+                       credentio_testing::GetFileReader(
+                           "c2pa/formats/zip/testing/asset_no_c2pa.zip"));
+  EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation(
+                  *input, {.requires_c2pa = true}),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No manifest store found")));
+}
+
+TEST(ZipExtractorTest, ExtractBoxesReturnsUnimplemented) {
+  ASSERT_OK_AND_ASSIGN(auto zip, CreateFakeZip({}));
+  riegeli::StringReader<> input(zip);
+
+  EXPECT_THAT(ZipExtractor().ExtractBoxes(input, {}),
+              StatusIs(absl::StatusCode::kUnimplemented,
+                       HasSubstr("ZIP does not have a concept of boxes")));
+}
+
+TEST(ZipExtractorTest, IsManifestStore) {
+  EXPECT_TRUE(ZipExtractor().MightBeC2paManifestStore(
+      CreateStartOfManifestStorePayload()));
+  EXPECT_FALSE(ZipExtractor().MightBeC2paManifestStore("not a manifest store"));
+}
+
+TEST(ZipExtractorTest,
+     ExtractManifestStoreThrowsErrorIfManifestStoreExceedsLimit) {
+  std::string large_content(17 * 1024 * 1024, 'a');
+  ASSERT_OK_AND_ASSIGN(auto zip,
+                       CreateFakeZip({
+                           {.name = "META-INF/content_credential.c2pa",
+                            .content = std::move(large_content)},
+                       }));
+  riegeli::StringReader<> input(zip);
+
+  EXPECT_THAT(ZipExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Manifest store size exceeds maximum limit")));
+}
+
+TEST(ZipExtractorTest, ExtractManifestStoreThrowsErrorIfCompressed) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 20);
+  write_le16(zip, 0);
+  write_le16(zip, 8);  // LFH compression method = 8 (deflated)
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 20);
+  std::string filename = "META-INF/content_credential.c2pa";
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("abcdefghij");
+
+  uint32_t cd_offset = zip.size();
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 20);
+  write_le16(zip, 20);
+  write_le16(zip, 0);
+  write_le16(zip, 8);  // CD compression method = 8 (deflated)
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 20);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(ZipExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Manifest store must not be compressed")));
+}
+
+TEST(ZipExtractorTest,
+     ExtractManifestStoreThrowsErrorIfUncompressedSizeExceedsLimit) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 20);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 17 * 1024 * 1024);  // Uncompressed size = 17MB
+  std::string filename = "META-INF/content_credential.c2pa";
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("abcdefghij");
+
+  uint32_t cd_offset = zip.size();
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 20);
+  write_le16(zip, 20);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 17 * 1024 * 1024);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(ZipExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Manifest store size exceeds maximum limit")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/format.cc b/formats/zip/format.cc
new file mode 100644
index 0000000..7c6856e
--- /dev/null
+++ b/formats/zip/format.cc
@@ -0,0 +1,41 @@
+// 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 "formats/zip/format.h"
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+#include "formats/zip/assessor.h"
+#include "formats/zip/extractor.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateZipFormat() {
+  return Format::Create(FormatOptions{
+      .assessor = std::make_unique<ZipAssessor>(),
+      .extractor = std::make_unique<ZipExtractor>(),
+      .mime_types =
+          {"application/"
+           "vnd.openxmlformats-officedocument.presentationml.presentation",
+           "application/"
+           "vnd.openxmlformats-officedocument.wordprocessingml.document",
+           "application/"
+           "vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
+  });
+}
+
+}  // namespace credentio
diff --git a/formats/zip/format.h b/formats/zip/format.h
new file mode 100644
index 0000000..48d7e79
--- /dev/null
+++ b/formats/zip/format.h
@@ -0,0 +1,30 @@
+// 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_FORMATS_ZIP_FORMAT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_FORMAT_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "formats/format.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<Format>> CreateZipFormat();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_FORMAT_H_
diff --git a/formats/zip/reader.cc b/formats/zip/reader.cc
new file mode 100644
index 0000000..d208981
--- /dev/null
+++ b/formats/zip/reader.cc
@@ -0,0 +1,409 @@
+// 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 "formats/zip/reader.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/memory/memory.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "absl/types/span.h"
+#include "formats/zip/constants.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+namespace {
+
+// Holds information from the End of Central Directory Record (EOCD) needed to
+// locate and read the central directory.
+struct CentralDirectoryLocator {
+  // Offset of the start of the central directory.
+  uint32_t offset;
+  // Total number of entries in the central directory.
+  uint16_t entry_count;
+};
+
+// Reads the End of Central Directory (EOCD) record from the end of the input
+// to locate the central directory.
+absl::StatusOr<CentralDirectoryLocator> FindCentralDirectory(
+    riegeli::Reader& input) {
+  if (!input.SupportsSize() || !input.Size().has_value()) {
+    return absl::InvalidArgumentError(
+        "Input does not support size or size is unknown.");
+  }
+  int64_t file_size = *input.Size();
+
+  if (file_size < kEocdRecordSize) {
+    return absl::InvalidArgumentError("Input is too small to be a ZIP file.");
+  }
+  if (!input.Seek(file_size - kEocdRecordSize)) {
+    return absl::InvalidArgumentError(
+        "Failed to seek to the end of the input.");
+  }
+
+  CentralDirectoryLocator locator;
+  std::string eocd_signature;
+  if (!input.Read(kZipEndOfCentralDirectorySignature.size(), eocd_signature) ||
+      eocd_signature != kZipEndOfCentralDirectorySignature) {
+    return absl::InvalidArgumentError(absl::Substitute(
+        "Invalid End of Central Directory record signature: $0",
+        eocd_signature));
+  }
+
+  // Skip the following fields since they are not needed for locating the
+  // central directory:
+  //   - Number of this disk (2 bytes)
+  //   - Number of the disk with the start of the central directory (2 bytes)
+  //   - Total number of entries in the central directory on this disk (2 bytes)
+  if (!input.Skip(6)) {
+    return absl::InvalidArgumentError(
+        "Failed to skip fields in the End of Central Directory record.");
+  }
+
+  // Total number of entries in the central directory (2 bytes, little-endian)
+  if (!riegeli::ReadLittleEndian<uint16_t>(input, locator.entry_count)) {
+    return absl::InvalidArgumentError(
+        "Failed to read the total number of entries in the central directory.");
+  }
+  if (locator.entry_count == 0xffff) {
+    return absl::UnimplementedError(
+        "ZIP64 format is not supported. Central directory entry "
+        "count must "
+        "not be 0xffff.");
+  }
+
+  // Skip the size of the central directory (4 bytes)
+  if (!input.Skip(4)) {
+    return absl::InvalidArgumentError(
+        "Failed to skip the size of the central directory.");
+  }
+
+  // Offset of the start of the central directory (4 bytes, little-endian)
+  if (!riegeli::ReadLittleEndian<uint32_t>(input, locator.offset)) {
+    return absl::InvalidArgumentError(
+        "Failed to read the offset of the start of the central directory.");
+  }
+  if (locator.offset == 0xffffffff) {
+    return absl::UnimplementedError(
+        "ZIP64 format is not supported. Central Directory offset "
+        "must not be "
+        "0xffffffff.");
+  }
+
+  return locator;
+}
+
+// Reads the central directory entries, and extracts file header information.
+absl::StatusOr<std::vector<ZipReader::FileEntry>>
+ExtractFileEntriesFromCentralDirectory(riegeli::Reader& input,
+                                       const CentralDirectoryLocator& locator) {
+  if (!input.Seek(locator.offset) || input.pos() != locator.offset) {
+    return absl::InvalidArgumentError(
+        "Failed to seek to the start of the central directory.");
+  }
+  std::vector<ZipReader::FileEntry> file_entries;
+  file_entries.reserve(locator.entry_count);
+
+  for (int i = 0; i < locator.entry_count; ++i) {
+    ZipReader::FileEntry entry;
+    entry.central_directory_header_offset = input.pos();
+
+    // Central directory signature (4 bytes)
+    std::string central_directory_signature;
+    if (!input.Read(kZipCentralDirectorySignature.size(),
+                    central_directory_signature) ||
+        central_directory_signature != kZipCentralDirectorySignature) {
+      return absl::InvalidArgumentError(
+          "Central directory signature not found.");
+    }
+
+    // Skip the following fields since they are not needed:
+    //   - Version made by (2 bytes)
+    //   - Version needed to extract (2 bytes)
+    if (!input.Skip(4)) {
+      return absl::InvalidArgumentError(
+          "Failed to skip version fields in the central directory entry.");
+    }
+
+    // General purpose bit flag (2 bytes, little-endian)
+    uint16_t general_purpose_bit_flag;
+    if (!riegeli::ReadLittleEndian<uint16_t>(input, general_purpose_bit_flag)) {
+      return absl::InvalidArgumentError(
+          "Failed to read the general purpose bit flag.");
+    }
+    // Bit 3 in the general purpose bit flag indicates whether the file has a
+    // data descriptor.
+    entry.has_data_descriptor = general_purpose_bit_flag & 0x0008;
+    entry.general_purpose_bit_flag = general_purpose_bit_flag;
+
+    // Compression method (2 bytes, little-endian)
+    if (!riegeli::ReadLittleEndian<uint16_t>(input, entry.compression_method)) {
+      return absl::InvalidArgumentError(
+          "Failed to read the compression method.");
+    }
+
+    // Skip the following fields since they are not needed:
+    //   - Last mod file time (2 bytes)
+    //   - Last mod file date (2 bytes)
+    //   - CRC-32 (4 bytes)
+    if (!input.Skip(8)) {
+      return absl::InvalidArgumentError(
+          "Failed to skip fields in the central directory entry.");
+    }
+
+    // Compressed size (4 bytes, little-endian)
+    uint32_t compressed_size;
+    if (!riegeli::ReadLittleEndian<uint32_t>(input, compressed_size)) {
+      return absl::InvalidArgumentError("Failed to read the compressed size.");
+    }
+    if (compressed_size == 0xffffffff) {
+      return absl::UnimplementedError(
+          "ZIP64 format is not supported. Compressed size must "
+          "not be "
+          "0xffffffff.");
+    }
+    std::optional<riegeli::Position> input_size = input.Size();
+    if (input_size.has_value() && compressed_size > *input_size) {
+      return absl::InvalidArgumentError("Compressed size exceeds file limits.");
+    }
+    entry.file_range.length = compressed_size;
+
+    // Uncompressed size (4 bytes, little-endian)
+    if (!riegeli::ReadLittleEndian<uint32_t>(input, entry.uncompressed_size)) {
+      return absl::InvalidArgumentError(
+          "Failed to read the uncompressed size.");
+    }
+
+    // File name length (2 bytes, little-endian)
+    uint16_t file_name_length;
+    if (!riegeli::ReadLittleEndian<uint16_t>(input, file_name_length)) {
+      return absl::InvalidArgumentError("Failed to read the file name length.");
+    }
+
+    // Extra field length (2 bytes, little-endian)
+    uint16_t extra_field_length;
+    if (!riegeli::ReadLittleEndian<uint16_t>(input, extra_field_length)) {
+      return absl::InvalidArgumentError(
+          "Failed to read the extra field length.");
+    }
+    entry.extra_field_length = extra_field_length;
+
+    // File comment length (2 bytes, little-endian)
+    uint16_t file_comment_length;
+    if (!riegeli::ReadLittleEndian<uint16_t>(input, file_comment_length)) {
+      return absl::InvalidArgumentError(
+          "Failed to read the file comment length.");
+    }
+    entry.file_comment_length = file_comment_length;
+
+    // Skip the following fields since they are not needed:
+    //   - Disk number (2 bytes)
+    //   - Internal file attributes (2 bytes)
+    //   - External file attributes (4 bytes)
+    if (!input.Skip(8)) {
+      return absl::InvalidArgumentError(
+          "Failed to skip fields in the central directory entry.");
+    }
+
+    // Local file header offset (4 bytes, little-endian)
+    uint32_t local_file_header_offset;
+    if (!riegeli::ReadLittleEndian<uint32_t>(input, local_file_header_offset)) {
+      return absl::InvalidArgumentError(
+          "Failed to read the local file header offset.");
+    }
+    entry.local_file_header_offset = local_file_header_offset;
+
+    // File name (length is determined by `file_name_length`)
+    if (!input.Read(file_name_length, entry.file_name)) {
+      return absl::InvalidArgumentError("Failed to read the file name.");
+    }
+
+    // Skip extra field and file comment in central directory
+    if (!input.Skip(extra_field_length + file_comment_length)) {
+      return absl::InvalidArgumentError(
+          "Failed to skip extra field and file comment in central directory.");
+    }
+
+    // The file data starts immediately after the local file header, which
+    // consists of:
+    //   - Fixed length fields (in total of 30 bytes)
+    //   - File name (variable length)
+    //   - Extra field (variable length)
+    entry.file_range.offset = entry.local_file_header_offset +
+                              kLocalFileHeaderFixedSize +
+                              entry.file_name.size() + entry.extra_field_length;
+
+    file_entries.push_back(entry);
+  }
+
+  // Reads and validates LFH signatures and ensures that the LFH
+  // extra_field_length matches the Central Directory requirement.
+  const int64_t cd_end_pos = input.pos();
+  ABSL_ASSIGN_OR_RETURN(auto sorted_entries,
+                        ZipReader::SortFileEntries(file_entries));
+
+  for (const auto& entry : sorted_entries) {
+    if (!input.Seek(entry.local_file_header_offset) ||
+        input.pos() != entry.local_file_header_offset) {
+      return absl::InvalidArgumentError(
+          "Failed to seek to local file header offset.");
+    }
+    std::string lfh_sig;
+    if (!input.Read(kZipLocalFileHeaderSignature.size(), lfh_sig) ||
+        lfh_sig != kZipLocalFileHeaderSignature) {
+      return absl::InvalidArgumentError("Invalid local file header signature.");
+    }
+    if (!input.Skip(2)) {
+      return absl::InvalidArgumentError(
+          "Failed to skip fields in local file header.");
+    }
+    uint16_t lfh_general_purpose_bit_flag;
+    if (!riegeli::ReadLittleEndian<uint16_t>(input,
+                                             lfh_general_purpose_bit_flag)) {
+      return absl::InvalidArgumentError(
+          "Failed to read general purpose bit flag from local file header.");
+    }
+    if ((entry.general_purpose_bit_flag & 0x0808) !=
+        (lfh_general_purpose_bit_flag & 0x0808)) {
+      return absl::InvalidArgumentError(
+          "Local File Header bit flag does not match Central Directory.");
+    }
+    uint16_t lfh_compression_method;
+    if (!riegeli::ReadLittleEndian<uint16_t>(input, lfh_compression_method)) {
+      return absl::InvalidArgumentError(
+          "Failed to read compression method from local file header.");
+    }
+    if (lfh_compression_method != entry.compression_method) {
+      return absl::InvalidArgumentError(
+          "Local File Header compression method does not match Central "
+          "Directory.");
+    }
+    if (!input.Skip(8)) {
+      return absl::InvalidArgumentError(
+          "Failed to skip fields in local file header.");
+    }
+    uint32_t lfh_compressed_size;
+    if (!riegeli::ReadLittleEndian<uint32_t>(input, lfh_compressed_size)) {
+      return absl::InvalidArgumentError(
+          "Failed to read compressed size from local file header.");
+    }
+    uint32_t lfh_uncompressed_size;
+    if (!riegeli::ReadLittleEndian<uint32_t>(input, lfh_uncompressed_size)) {
+      return absl::InvalidArgumentError(
+          "Failed to read uncompressed size from local file header.");
+    }
+    if (!entry.has_data_descriptor) {
+      if (lfh_compressed_size != entry.file_range.length ||
+          lfh_uncompressed_size != entry.uncompressed_size) {
+        return absl::InvalidArgumentError(
+            "Local File Header sizes do not match Central Directory.");
+      }
+    } else {
+      if ((lfh_compressed_size != 0 &&
+           lfh_compressed_size != entry.file_range.length) ||
+          (lfh_uncompressed_size != 0 &&
+           lfh_uncompressed_size != entry.uncompressed_size)) {
+        return absl::InvalidArgumentError(
+            "Local File Header sizes do not match Central Directory.");
+      }
+    }
+    uint16_t lfh_file_name_length;
+    if (!riegeli::ReadLittleEndian<uint16_t>(input, lfh_file_name_length)) {
+      return absl::InvalidArgumentError(
+          "Failed to read file name length from local file header.");
+    }
+    if (lfh_file_name_length != entry.file_name.size()) {
+      return absl::InvalidArgumentError(
+          "Local File Header file name length does not match Central "
+          "Directory.");
+    }
+    uint16_t lfh_extra_field_length;
+    if (!riegeli::ReadLittleEndian<uint16_t>(input, lfh_extra_field_length)) {
+      return absl::InvalidArgumentError(
+          "Failed to read extra field length from local file header.");
+    }
+    if (lfh_extra_field_length != entry.extra_field_length) {
+      return absl::InvalidArgumentError(
+          "Local File Header extra field length does not match Central "
+          "Directory.");
+    }
+  }
+
+  if (!input.Seek(cd_end_pos) || input.pos() != cd_end_pos) {
+    return absl::InvalidArgumentError(
+        "Failed to seek back to central directory position.");
+  }
+
+  return sorted_entries;
+}
+
+}  // namespace
+
+// Sorts ZIP file entries by header offset and checks that they do not have
+// duplicate offsets or overlapping ranges to prevent ZIP bomb conditions.
+absl::StatusOr<std::vector<ZipReader::FileEntry>> ZipReader::SortFileEntries(
+    absl::Span<const ZipReader::FileEntry> file_entries) {
+  std::vector<ZipReader::FileEntry> sorted_entries(file_entries.begin(),
+                                                   file_entries.end());
+  std::sort(sorted_entries.begin(), sorted_entries.end(),
+            [](const ZipReader::FileEntry& a, const ZipReader::FileEntry& b) {
+              return a.local_file_header_offset < b.local_file_header_offset;
+            });
+
+  int64_t previous_entry_end = 0;
+  for (const auto& entry : sorted_entries) {
+    if (entry.local_file_header_offset < previous_entry_end) {
+      return absl::InvalidArgumentError(
+          "Overlapping or duplicate ZIP entries detected.");
+    }
+    previous_entry_end = entry.file_range.offset + entry.file_range.length;
+  }
+  return sorted_entries;
+}
+
+absl::StatusOr<std::unique_ptr<ZipReader>> ZipReader::Create(
+    riegeli::Reader* absl_nonnull input) {
+  ABSL_ASSIGN_OR_RETURN(CentralDirectoryLocator locator,
+                        FindCentralDirectory(*input));
+
+  ABSL_ASSIGN_OR_RETURN(
+      std::vector<FileEntry> file_entries,
+      ExtractFileEntriesFromCentralDirectory(*input, locator));
+
+  auto reader =
+      absl::WrapUnique(new ZipReader(std::move(file_entries), locator.offset));
+  return std::move(reader);
+}
+
+absl::StatusOr<ZipReader::FileEntry> ZipReader::Next() {
+  if (!HasNext()) {
+    return absl::OutOfRangeError("No more file entries to read.");
+  }
+  return *current_it_++;
+}
+}  // namespace credentio
diff --git a/formats/zip/reader.h b/formats/zip/reader.h
new file mode 100644
index 0000000..8c061f0
--- /dev/null
+++ b/formats/zip/reader.h
@@ -0,0 +1,101 @@
+// 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_FORMATS_ZIP_READER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_READER_H_
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/attributes.h"
+#include "absl/base/nullability.h"
+#include "absl/status/statusor.h"
+#include "absl/types/span.h"
+#include "formats/byte_range.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Zip Reader for C2PA, holds onto a reference to a `riegeli::Reader` which
+// must outlive the reader.
+class ZipReader {
+ public:
+  // Represents a file entry in a ZIP archive, derived from the file header in
+  // the central directory.
+  struct FileEntry {
+    uint64_t central_directory_header_offset = 0;
+    uint64_t local_file_header_offset = 0;
+    std::string file_name;
+    ByteRange file_range;
+    bool has_data_descriptor = false;
+    uint16_t general_purpose_bit_flag = 0;
+    uint16_t compression_method = 0;
+    uint32_t uncompressed_size = 0;
+    uint16_t extra_field_length = 0;
+    uint16_t file_comment_length = 0;
+
+    bool operator==(const FileEntry& other) const {
+      return central_directory_header_offset ==
+                 other.central_directory_header_offset &&
+             local_file_header_offset == other.local_file_header_offset &&
+             file_name == other.file_name && file_range == other.file_range &&
+             has_data_descriptor == other.has_data_descriptor &&
+             general_purpose_bit_flag == other.general_purpose_bit_flag &&
+             compression_method == other.compression_method &&
+             uncompressed_size == other.uncompressed_size &&
+             extra_field_length == other.extra_field_length &&
+             file_comment_length == other.file_comment_length;
+    }
+  };
+
+  static absl::StatusOr<std::unique_ptr<ZipReader>> Create(
+      riegeli::Reader* absl_nonnull input ABSL_ATTRIBUTE_LIFETIME_BOUND);
+
+  // Sorts ZIP file entries by header offset and checks that they do not have
+  // duplicate offsets or overlapping ranges to prevent ZIP bomb conditions.
+  static absl::StatusOr<std::vector<ZipReader::FileEntry>> SortFileEntries(
+      absl::Span<const ZipReader::FileEntry> file_entries);
+
+  // Returns the offset of the start of the central directory.
+  uint64_t central_directory_offset() const {
+    return central_directory_offset_;
+  }
+
+  // Returns true if there is a file entry to read.
+  bool HasNext() const { return current_it_ < file_entries_.end(); };
+
+  // Reads the next file entry in the ZIP archive. Returns an OUT_OF_RANGE
+  // error if there are no more entries to read.
+  absl::StatusOr<FileEntry> Next();
+
+ private:
+  explicit ZipReader(std::vector<ZipReader::FileEntry>&& file_entries,
+                     uint64_t central_directory_offset)
+      : file_entries_(std::move(file_entries)),
+        current_it_(file_entries_.begin()),
+        central_directory_offset_(central_directory_offset) {}
+
+  // All the file entries in the ZIP archive.
+  const std::vector<ZipReader::FileEntry> file_entries_;
+  std::vector<ZipReader::FileEntry>::const_iterator current_it_;
+
+  uint64_t central_directory_offset_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_READER_H_
diff --git a/formats/zip/reader_test.cc b/formats/zip/reader_test.cc
new file mode 100644
index 0000000..2bbbfa5
--- /dev/null
+++ b/formats/zip/reader_test.cc
@@ -0,0 +1,1130 @@
+// 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 "formats/zip/reader.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <cstdlib>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "formats/byte_range.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/cfile_reader.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::AllOf;
+using ::testing::Contains;
+using ::testing::Field;
+using ::testing::HasSubstr;
+
+void write_le16(std::string& s, uint16_t val) {
+  s.push_back(static_cast<char>(val & 0xff));
+  s.push_back(static_cast<char>((val >> 8) & 0xff));
+}
+
+void write_le32(std::string& s, uint32_t val) {
+  s.push_back(static_cast<char>(val & 0xff));
+  s.push_back(static_cast<char>((val >> 8) & 0xff));
+  s.push_back(static_cast<char>((val >> 16) & 0xff));
+  s.push_back(static_cast<char>((val >> 24) & 0xff));
+}
+
+std::string MakeLfh(absl::string_view filename) {
+  std::string lfh;
+  lfh.append("PK\x03\x04", 4);
+  write_le16(lfh, 10);  // version needed
+  write_le16(lfh, 0);   // GP flag
+  write_le16(lfh, 0);   // comp method
+  write_le16(lfh, 0);   // last mod time
+  write_le16(lfh, 0);   // last mod date
+  write_le32(lfh, 0);   // crc32
+  write_le32(lfh, 0);   // compressed size
+  write_le32(lfh, 0);   // uncompressed size
+  write_le16(lfh, filename.size());
+  write_le16(lfh, 0);  // extra field length
+  lfh.append(filename);
+  return lfh;
+}
+
+std::string MakeCdEntry(absl::string_view filename,
+                        uint32_t local_header_offset) {
+  std::string entry;
+  entry.append("PK\x01\x02", 4);
+  write_le16(entry, 0);  // version made by
+  write_le16(entry, 0);  // version needed
+  write_le16(entry, 0);  // GP flag
+  write_le16(entry, 0);  // comp method
+  write_le16(entry, 0);  // last mod time
+  write_le16(entry, 0);  // last mod date
+  write_le32(entry, 0);  // crc32
+  write_le32(entry, 0);  // compressed size
+  write_le32(entry, 0);  // uncompressed size
+  write_le16(entry, filename.size());
+  write_le16(entry, 0);  // extra field length
+  write_le16(entry, 0);  // file comment length
+  write_le16(entry, 0);  // disk number start
+  write_le16(entry, 0);  // internal attr
+  write_le32(entry, 0);  // external attr
+  write_le32(entry, local_header_offset);
+  entry.append(filename);
+  return entry;
+}
+
+std::string MakeEocd(uint32_t cd_size, uint32_t cd_offset,
+                     uint16_t entry_count) {
+  std::string eocd;
+  eocd.append("PK\x05\x06", 4);
+  write_le16(eocd, 0);            // disk number
+  write_le16(eocd, 0);            // disk where CD starts
+  write_le16(eocd, entry_count);  // CD records on this disk
+  write_le16(eocd, entry_count);  // total CD records
+  write_le32(eocd, cd_size);
+  write_le32(eocd, cd_offset);
+  write_le16(eocd, 0);  // comment length
+  return eocd;
+}
+
+std::string GetTestDataPath(absl::string_view relative_path) {
+  const char* test_srcdir = std::getenv("TEST_SRCDIR");
+  const char* test_workspace = std::getenv("TEST_WORKSPACE");
+  if (test_srcdir == nullptr || test_workspace == nullptr) {
+    return std::string(relative_path);
+  }
+  return absl::StrCat(test_srcdir, "/", test_workspace, "/", relative_path);
+}
+
+constexpr char kEmptyZipFile[] = {
+    'P',  'K',  '\5', '\6', '\0', '\0', '\0', '\0', '\0', '\0', '\0',
+    '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0',
+};
+
+TEST(ZipReaderTest, FileTooSmall) {
+  std::string file_contents = "PK\x03\x04\xab\xcd\xab\xcd\xab\xcd";
+  riegeli::StringReader<> input(file_contents);
+
+  EXPECT_THAT(ZipReader::Create(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Input is too small to be a ZIP file")));
+}
+
+TEST(ZipReaderTest, InvalidEocdSignature) {
+  std::string file_contents = "PKPKPK12345678901234567890";
+  riegeli::StringReader<> input(file_contents);
+
+  EXPECT_THAT(
+      ZipReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Invalid End of Central Directory record signature")));
+}
+
+TEST(ZipReaderTest, InvalidCentralDirectorySignature) {
+  std::string file_contents = std::string(
+      "PK\x01\x03"
+      "PK\x05\x06"
+      "\x00\x00"
+      "\x00\x00"
+      "\x01\x00"
+      "\x01\x00"
+      "\x04\x00\x00\x00"
+      "\x00\x00\x00\x00"
+      "\x00\x00",
+      26);
+  riegeli::StringReader<> input(file_contents);
+
+  EXPECT_THAT(ZipReader::Create(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Central directory signature not found")));
+}
+
+TEST(ZipReaderTest, EmptyZipFile) {
+  std::string empty_zip_file_contents(kEmptyZipFile, sizeof(kEmptyZipFile));
+  riegeli::StringReader<> input(empty_zip_file_contents);
+  auto reader_or = ZipReader::Create(&input);
+  ASSERT_THAT(reader_or, IsOk());
+  auto reader = std::move(*reader_or);
+
+  EXPECT_FALSE(reader->HasNext());
+}
+
+TEST(ZipReaderTest, Zip64Unsupported) {
+  std::string zip = GetTestDataPath(
+      "formats/zip/testing/"
+      "asset_unsupported_zip64.zip");
+  riegeli::CFileReader<> input(zip);
+  ASSERT_THAT(input.status(), IsOk());
+
+  EXPECT_THAT(ZipReader::Create(&input),
+              StatusIs(absl::StatusCode::kUnimplemented,
+                       HasSubstr("ZIP64 format is not supported")));
+}
+
+TEST(ZipReaderTest, DetectsDataDescriptorCorrectly) {
+  // clang-format off
+  constexpr char kZipWithDataDescriptor[] = {
+      // Local File Header
+      'P', 'K', '\x03', '\x04',
+      '\x00', '\x00',                  // Version needed to extract
+      '\x08', '\x00',                  // General purpose bit flag
+      '\x00', '\x00',                  // Compression method
+      '\x00', '\x00',                  // Last mod file time
+      '\x00', '\x00',                  // Last mod file date
+      '\x00', '\x00', '\x00', '\x00',  // CRC-32
+      '\x00', '\x00', '\x00', '\x00',  // Compressed size
+      '\x00', '\x00', '\x00', '\x00',  // Uncompressed size
+      '\x01', '\x00',                  // File name length (1 byte)
+      '\x00', '\x00',                  // Extra field length
+      'a',                             // File name (1 byte)
+      // Central Directory Record
+      'P', 'K', '\x01', '\x02',
+      '\x00', '\x00',                  // Version made by
+      '\x00', '\x00',                  // Version needed to extract
+      '\x08', '\x00',                  // General purpose bit flag (Bit 3 set)
+      '\x00', '\x00',                  // Compression method
+      '\x00', '\x00',                  // Last mod file time
+      '\x00', '\x00',                  // Last mod file date
+      '\x00', '\x00', '\x00', '\x00',  // CRC-32
+      '\x00', '\x00', '\x00', '\x00',  // Compressed size
+      '\x00', '\x00', '\x00', '\x00',  // Uncompressed size
+      '\x01', '\x00',                  // File name length (1 byte)
+      '\x00', '\x00',                  // Extra field length
+      '\x00', '\x00',                  // File comment length
+      '\x00', '\x00',                  // Disk number start
+      '\x00', '\x00',                  // Internal file attributes
+      '\x00', '\x00', '\x00', '\x00',  // External file attributes
+      '\x00', '\x00', '\x00', '\x00',  // Relative offset of local header
+      'a',                             // File name (1 byte)
+      // End of central directory record (EOCD)
+      'P', 'K', '\x05', '\x06',
+      '\x00', '\x00',                  // Number of this disk
+      '\x00', '\x00',                  // Disk where CD starts
+      '\x01', '\x00',                  // Number of CD records on this disk
+      '\x01', '\x00',                  // Total number of CD records
+      '\x2f', '\x00', '\x00', '\x00',  // Size of CD (47 bytes)
+      '\x1f', '\x00', '\x00', '\x00',  // Offset of start of CD (31 bytes)
+      '\x00', '\x00',                  // ZIP file comment length (0 bytes)
+  };
+  // clang-format on
+  std::string zip_contents(kZipWithDataDescriptor,
+                           sizeof(kZipWithDataDescriptor));
+  riegeli::StringReader<> input(zip_contents);
+  auto reader_or = ZipReader::Create(&input);
+  ASSERT_THAT(reader_or, IsOk());
+  auto reader = std::move(*reader_or);
+
+  ASSERT_TRUE(reader->HasNext());
+  auto entry_or = reader->Next();
+  ASSERT_THAT(entry_or, IsOk());
+  auto entry = std::move(*entry_or);
+
+  EXPECT_TRUE(entry.has_data_descriptor);
+}
+
+TEST(ZipReaderTest, ReadsZipFile) {
+  std::string zip = GetTestDataPath("formats/zip/testing/asset_no_c2pa.zip");
+  riegeli::CFileReader<> input(zip);
+  ASSERT_THAT(input.status(), IsOk());
+  auto reader_or = ZipReader::Create(&input);
+  ASSERT_THAT(reader_or, IsOk());
+  auto reader = std::move(*reader_or);
+
+  std::vector<ZipReader::FileEntry> entries;
+  while (reader->HasNext()) {
+    auto entry_or = reader->Next();
+    ASSERT_THAT(entry_or, IsOk());
+    entries.push_back(std::move(*entry_or));
+  }
+
+  EXPECT_THAT(entries,
+              testing::UnorderedElementsAre(
+                  ZipReader::FileEntry{
+                      .central_directory_header_offset = 139,
+                      .local_file_header_offset = 0,
+                      .file_name = "file_1.txt",
+                      .file_range = ByteRange{.offset = 40, .length = 44},
+                      .has_data_descriptor = false,
+                      .general_purpose_bit_flag = 0,
+                      .compression_method = 0,
+                      .uncompressed_size = 44,
+                  },
+                  ZipReader::FileEntry{
+                      .central_directory_header_offset = 195,
+                      .local_file_header_offset = 84,
+                      .file_name = "file_2.txt",
+                      .file_range = ByteRange{.offset = 124, .length = 15},
+                      .has_data_descriptor = false,
+                      .general_purpose_bit_flag = 0,
+                      .compression_method = 0,
+                      .uncompressed_size = 15,
+                  }));
+  EXPECT_THAT(reader->Next(),
+              StatusIs(absl::StatusCode::kOutOfRange,
+                       HasSubstr("No more file entries to read")));
+}
+
+TEST(ZipReaderTest, ReadsPptxFile) {
+  std::string pptx =
+      GetTestDataPath("formats/zip/testing/no_c2pa_032_asset.pptx");
+  riegeli::CFileReader<> input(pptx);
+  ASSERT_THAT(input.status(), IsOk());
+  auto reader_or = ZipReader::Create(&input);
+  ASSERT_THAT(reader_or, IsOk());
+  auto reader = std::move(*reader_or);
+
+  std::vector<ZipReader::FileEntry> entries;
+  while (reader->HasNext()) {
+    auto entry_or = reader->Next();
+    ASSERT_THAT(entry_or, IsOk());
+    entries.push_back(std::move(*entry_or));
+  }
+
+  EXPECT_EQ(entries.size(), 46);
+  // OOXML files should have a [Content_Types].xml file entry with a data
+  // descriptor.
+  EXPECT_THAT(
+      entries,
+      Contains(
+          AllOf(Field(&ZipReader::FileEntry::file_name, "[Content_Types].xml"),
+                Field(&ZipReader::FileEntry::has_data_descriptor, true))));
+}
+
+TEST(ZipReaderTest, CentralDirectoryOffsetEmptyZip) {
+  std::string empty_zip_file_contents(kEmptyZipFile, sizeof(kEmptyZipFile));
+  riegeli::StringReader<> input(empty_zip_file_contents);
+  auto reader_or = ZipReader::Create(&input);
+  ASSERT_THAT(reader_or, IsOk());
+  auto reader = std::move(*reader_or);
+
+  EXPECT_EQ(reader->central_directory_offset(), 0);
+}
+
+TEST(ZipReaderTest, CentralDirectoryOffset) {
+  std::string zip = GetTestDataPath("formats/zip/testing/asset_no_c2pa.zip");
+  riegeli::CFileReader<> input(zip);
+  ASSERT_THAT(input.status(), IsOk());
+  auto reader_or = ZipReader::Create(&input);
+  ASSERT_THAT(reader_or, IsOk());
+  auto reader = std::move(*reader_or);
+
+  EXPECT_EQ(reader->central_directory_offset(), 139);
+}
+
+TEST(ZipReaderTest, LocalFileHeaderExtraFieldMismatchRejected) {
+  std::string zip;
+  // Local File Header for file_1.txt
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  std::string filename = "file_1.txt";
+  write_le16(zip, filename.size());
+  write_le16(zip, 8);  // LFH Extra field length = 8!
+  zip.append(filename);
+  zip.append("12345678");    // LFH Extra field data
+  zip.append("abcdefghij");  // Actual file data (10 bytes)
+
+  uint32_t cd_offset = zip.size();
+  // Central Directory Record for file_1.txt
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);  // CD Extra field length = 0! Mismatched!
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  // End of Central Directory (EOCD)
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(
+      ZipReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Local File Header extra field length does not match "
+                         "Central Directory")));
+}
+
+TEST(ZipReaderTest, LocalFileHeaderInvalidSignatureRejected) {
+  std::string zip;
+  // Local File Header with corrupted signature
+  zip.append("PK\x03\x05", 4);  // PK\x03\x05 instead of PK\x03\x04!
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  std::string filename = "file_1.txt";
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("abcdefghij");
+
+  uint32_t cd_offset = zip.size();
+  // Central Directory Record
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);  // Local Header Offset = 0
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  // End of Central Directory (EOCD)
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(ZipReader::Create(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Invalid local file header signature")));
+}
+
+TEST(ZipReaderTest, LocalFileHeaderSeekFailureRejected) {
+  std::string zip;
+  // No LFH at offset 0 (we start with CD)
+  uint32_t cd_offset = zip.size();
+  std::string filename = "file_1.txt";
+  // Central Directory Record
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 9999);  // Local Header Offset points to invalid offset 9999!
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  // End of Central Directory (EOCD)
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(
+      ZipReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Failed to seek to local file header offset")));
+}
+
+TEST(ZipReaderTest, VulnerabilityZipEntryOverlapDetected) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  std::string filename = "file_1.txt";
+  // Local File Header at offset 0
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("0123456789", 10);
+
+  uint32_t cd_offset = zip.size();
+  // Central Directory Record 1 pointing to offset 0
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  // Central Directory Record 2 duplicates offset 0 (overlapping/duplicate)
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  // End of Central Directory (EOCD)
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 2);
+  write_le16(zip, 2);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(
+      ZipReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Overlapping or duplicate ZIP entries detected")));
+}
+
+TEST(ZipReaderTest, CompressedSizeExceedsFileLimitsRejected) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  // Local File Header for file_1.txt
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  std::string filename = "file_1.txt";
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("abcdefghij");
+
+  uint32_t cd_offset = zip.size();
+  // Central Directory Record for file_1.txt
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0x7FFFFFFF);  // CD Compressed size exceeds file size
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  // End of Central Directory (EOCD)
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(ZipReader::Create(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Compressed size exceeds file limits")));
+}
+
+class MockLargeZipReader : public riegeli::Reader {
+ public:
+  MockLargeZipReader(std::string lfh1, std::string lfh2, std::string cd,
+                     std::string eocd, uint64_t lfh2_offset, uint64_t cd_offset,
+                     uint64_t eocd_offset)
+      : lfh1_(lfh1),
+        lfh2_(lfh2),
+        cd_(cd),
+        eocd_(eocd),
+        lfh2_offset_(lfh2_offset),
+        cd_offset_(cd_offset),
+        eocd_offset_(eocd_offset),
+        size_(eocd_offset + eocd.size()) {
+    set_limit_pos(0);
+  }
+
+  bool SupportsRandomAccess() override { return true; }
+  bool SupportsSize() override { return true; }
+
+ protected:
+  bool PullSlow(size_t min_length, size_t recommended_length) override {
+    uint64_t current_pos = pos();
+    if (current_pos >= size_) return false;
+
+    if (current_pos < lfh1_.size()) {
+      set_buffer(lfh1_.data(), lfh1_.size(), current_pos);
+      set_limit_pos(lfh1_.size());
+      return true;
+    }
+    if (current_pos >= lfh2_offset_ &&
+        current_pos < lfh2_offset_ + lfh2_.size()) {
+      set_buffer(lfh2_.data(), lfh2_.size(), current_pos - lfh2_offset_);
+      set_limit_pos(lfh2_offset_ + lfh2_.size());
+      return true;
+    }
+    if (current_pos >= cd_offset_ && current_pos < cd_offset_ + cd_.size()) {
+      set_buffer(cd_.data(), cd_.size(), current_pos - cd_offset_);
+      set_limit_pos(cd_offset_ + cd_.size());
+      return true;
+    }
+    if (current_pos >= eocd_offset_ &&
+        current_pos < eocd_offset_ + eocd_.size()) {
+      set_buffer(eocd_.data(), eocd_.size(), current_pos - eocd_offset_);
+      set_limit_pos(eocd_offset_ + eocd_.size());
+      return true;
+    }
+
+    // Gap handling
+    uint64_t next_offset = size_;
+    if (current_pos < lfh2_offset_) {
+      next_offset = lfh2_offset_;
+    } else if (current_pos < cd_offset_) {
+      next_offset = cd_offset_;
+    } else if (current_pos < eocd_offset_) {
+      next_offset = eocd_offset_;
+    }
+
+    uint64_t remaining = next_offset - current_pos;
+    size_t chunk =
+        std::min(sizeof(kZeroBuffer), static_cast<size_t>(remaining));
+    set_buffer(kZeroBuffer, chunk, 0);
+    set_limit_pos(current_pos + chunk);
+    return true;
+  }
+
+  bool SeekSlow(riegeli::Position new_pos) override {
+    if (new_pos > size_) {
+      set_buffer();
+      set_limit_pos(size_);
+      return false;
+    }
+    set_buffer();
+    set_limit_pos(new_pos);
+    return true;
+  }
+
+  std::optional<riegeli::Position> SizeImpl() override { return size_; }
+
+ private:
+  static constexpr char kZeroBuffer[1024] = {0};
+
+  std::string lfh1_;
+  std::string lfh2_;
+  std::string cd_;
+  std::string eocd_;
+  uint64_t lfh2_offset_;
+  uint64_t cd_offset_;
+  uint64_t eocd_offset_;
+  uint64_t size_;
+};
+
+TEST(ZipReaderTest, LargeZipFile) {
+  // We want to test that ZipReader can handle file entries that start after
+  // 4GB. We construct a mock ZIP file layout:
+  // - LFH1 at 0 (size 40)
+  // - Large file data of size 4294967063 (approx 4GB)
+  // - LFH2 at 4294967103 (size 43)
+  // - File 2 (manifest) data of size 100
+  // - CD at 4294967250 (starts before 4GB, ends after 4GB)
+  //   - CD Entry 1 (for large_file) starts at 4294967250, size 56.
+  //   - CD Entry 2 (for manifest.json) starts at 4294967306 (after 4GB!),
+  //   size 59.
+  // - EOCD at 4294967365 (size 22)
+  // Total size = 4294967387.
+
+  std::string filename1 = "large_file";
+  std::string filename2 = "manifest.json";
+
+  std::string lfh1 = MakeLfh(filename1);
+  std::string lfh2 = MakeLfh(filename2);
+
+  uint64_t lfh2_offset = 4294967103;
+  uint64_t cd_offset = 4294967250;
+
+  std::string cd_entry1 = MakeCdEntry(filename1, 0);
+  std::string cd_entry2 = MakeCdEntry(filename2, lfh2_offset);
+  std::string cd = cd_entry1 + cd_entry2;
+
+  uint64_t eocd_offset = cd_offset + cd.size();
+  std::string eocd = MakeEocd(cd.size(), cd_offset, 2);
+
+  MockLargeZipReader input(lfh1, lfh2, cd, eocd, lfh2_offset, cd_offset,
+                           eocd_offset);
+
+  auto reader_or = ZipReader::Create(&input);
+  ASSERT_THAT(reader_or, IsOk());
+  auto reader = std::move(*reader_or);
+
+  std::vector<ZipReader::FileEntry> entries;
+  while (reader->HasNext()) {
+    auto entry_or = reader->Next();
+    ASSERT_THAT(entry_or, IsOk());
+    entries.push_back(std::move(*entry_or));
+  }
+
+  EXPECT_THAT(
+      entries,
+      testing::UnorderedElementsAre(
+          ZipReader::FileEntry{
+              .central_directory_header_offset = cd_offset,
+              .local_file_header_offset = 0,
+              .file_name = filename1,
+              .file_range = ByteRange{.offset = 40, .length = 0},
+              .has_data_descriptor = false,
+          },
+          ZipReader::FileEntry{
+              .central_directory_header_offset = cd_offset + cd_entry1.size(),
+              .local_file_header_offset = lfh2_offset,
+              .file_name = filename2,
+              .file_range =
+                  ByteRange{.offset = lfh2_offset + 30 + filename2.size(),
+                            .length = 0},
+              .has_data_descriptor = false,
+          }));
+}
+
+TEST(ZipReaderTest, LocalFileHeaderSizeMismatchRejected) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  // Local File Header with compressed_size = 999
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 999);  // LFH compressed size = 999!
+  write_le32(zip, 10);
+  std::string filename = "file_1.txt";
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("abcdefghij");
+
+  uint32_t cd_offset = zip.size();
+  // Central Directory Record with compressed_size = 10
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);  // CD compressed size = 10!
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(ZipReader::Create(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Local File Header sizes do not match Central "
+                                 "Directory")));
+}
+
+TEST(ZipReaderTest, LocalFileHeaderFileNameLengthMismatchRejected) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  std::string filename = "file_1.txt";
+  write_le16(zip, filename.size() + 5);  // LFH filename length mismatched!
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("abcdefghij");
+
+  uint32_t cd_offset = zip.size();
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(
+      ZipReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Local File Header file name length does not match "
+                         "Central Directory")));
+}
+
+TEST(ZipReaderTest, LocalFileHeaderBitFlagMismatchRejected) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 0x0800);  // LFH has UTF-8 flag set
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  std::string filename = "file_1.txt";
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("abcdefghij");
+
+  uint32_t cd_offset = zip.size();
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);  // CD bit flag is 0
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(
+      ZipReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Local File Header bit flag does not match Central "
+                         "Directory")));
+}
+
+TEST(ZipReaderTest, LocalFileHeaderCompressionMethodMismatchRejected) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 8);  // LFH compression method = 8
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  std::string filename = "file_1.txt";
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  zip.append(filename);
+  zip.append("abcdefghij");
+
+  uint32_t cd_offset = zip.size();
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);  // CD compression method = 0
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  zip.append(filename);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  write_le32(zip, cd_size);
+  write_le32(zip, cd_offset);
+  write_le16(zip, 0);
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(
+      ZipReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Local File Header compression method does not match "
+                         "Central Directory")));
+}
+
+TEST(ZipReaderTest, LocalFileHeaderTruncatedRejected) {
+  auto write_le16 = [](std::string& s, uint16_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+  };
+  auto write_le32 = [](std::string& s, uint32_t val) {
+    s.push_back(static_cast<char>(val & 0xff));
+    s.push_back(static_cast<char>((val >> 8) & 0xff));
+    s.push_back(static_cast<char>((val >> 16) & 0xff));
+    s.push_back(static_cast<char>((val >> 24) & 0xff));
+  };
+
+  std::string zip;
+  uint32_t cd_offset = 0;
+  std::string filename = "file_1.txt";
+
+  // CD Record at offset 0
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 10);
+  write_le32(zip, 10);
+  write_le16(zip, filename.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+
+  uint32_t lfh_offset = 46 + filename.size() + 12;  // Offset 12 inside EOCD
+  write_le32(zip, lfh_offset);
+  zip.append(filename);
+
+  // EOCD Record
+  zip.append("PK\x05\x06", 4);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 1);
+  write_le16(zip, 1);
+  zip.append("PK\x03\x04", 4);  // Size of CD field serves as fake LFH signature
+  write_le32(zip, cd_offset);   // Offset of CD = 0 (serves as flags = 0)
+  write_le16(zip, 0);  // Comment length = 0 (serves as comp method = 0)
+
+  riegeli::StringReader<> input(zip);
+  EXPECT_THAT(
+      ZipReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Failed to skip fields in local file header")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/testing/BUILD b/formats/zip/testing/BUILD
new file mode 100644
index 0000000..ac9a788
--- /dev/null
+++ b/formats/zip/testing/BUILD
@@ -0,0 +1,24 @@
+# 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.
+#
+
+package(default_visibility = ["//visibility:public"])
+
+filegroup(
+    name = "assets",
+    srcs = glob(include = [
+        "*.pptx",
+        "*.zip",
+    ]),
+)
diff --git a/formats/zip/testing/asset_fake_c2pa.zip b/formats/zip/testing/asset_fake_c2pa.zip
new file mode 100644
index 0000000..b7a66a7
--- /dev/null
+++ b/formats/zip/testing/asset_fake_c2pa.zip
Binary files differ
diff --git a/formats/zip/testing/asset_no_c2pa.zip b/formats/zip/testing/asset_no_c2pa.zip
new file mode 100644
index 0000000..8ac2a7e
--- /dev/null
+++ b/formats/zip/testing/asset_no_c2pa.zip
Binary files differ
diff --git a/formats/zip/testing/asset_unsupported_zip64.zip b/formats/zip/testing/asset_unsupported_zip64.zip
new file mode 100644
index 0000000..ddb4b00
--- /dev/null
+++ b/formats/zip/testing/asset_unsupported_zip64.zip
Binary files differ
diff --git a/formats/zip/testing/no_c2pa_032_asset.pptx b/formats/zip/testing/no_c2pa_032_asset.pptx
new file mode 100644
index 0000000..b57fc29
--- /dev/null
+++ b/formats/zip/testing/no_c2pa_032_asset.pptx
Binary files differ
diff --git a/jumbf/BUILD b/jumbf/BUILD
new file mode 100644
index 0000000..a22c1e4
--- /dev/null
+++ b/jumbf/BUILD
@@ -0,0 +1,224 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "constants",
+    hdrs = ["constants.h"],
+    deps = ["//uuid"],
+)
+
+cc_library(
+    name = "test_utils",
+    testonly = True,
+    srcs = ["test_utils.cc"],
+    hdrs = ["test_utils.h"],
+    deps = [
+        ":constants",
+        "//uuid",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "box",
+    hdrs = ["box.h"],
+    deps = [
+        "//uuid",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "box_builder",
+    srcs = ["box_builder.cc"],
+    hdrs = ["box_builder.h"],
+    deps = [
+        ":constants",
+        ":encode_params",
+        "//jumbf/internal:encode",
+        "//uuid",
+        "@abseil-cpp//absl/algorithm:container",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/bytes:cord_backward_writer",
+    ],
+)
+
+cc_test(
+    name = "box_builder_test",
+    srcs = ["box_builder_test.cc"],
+    deps = [
+        ":box",
+        ":box_builder",
+        ":constants",
+        ":parse",
+        ":test_utils",
+        "//uuid",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "parse",
+    srcs = ["parse.cc"],
+    hdrs = ["parse.h"],
+    deps = [
+        ":box",
+        ":constants",
+        "//jumbf/internal:consume_box",
+        "//jumbf/internal:intermediate",
+        "//jumbf/internal:parse_embedded_file_description_box",
+        "//jumbf/internal:parse_super_box",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "parse_test",
+    srcs = ["parse_test.cc"],
+    deps = [
+        ":box",
+        ":constants",
+        ":parse",
+        ":test_utils",
+        "//uuid",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "uri",
+    srcs = ["uri.cc"],
+    hdrs = ["uri.h"],
+    deps = [
+        ":box",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "uri_test",
+    srcs = ["uri_test.cc"],
+    deps = [
+        ":box",
+        ":uri",
+        "//uuid",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "utils",
+    srcs = ["utils.cc"],
+    hdrs = ["utils.h"],
+    deps = [
+        ":box",
+        ":constants",
+        "//jumbf/internal:serialization_utils",
+        "//uuid",
+        "@abseil-cpp//absl/functional:overload",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "encode_params",
+    hdrs = ["encode_params.h"],
+    deps = ["@abseil-cpp//absl/strings:cord"],
+)
+
+cc_test(
+    name = "utils_test",
+    srcs = ["utils_test.cc"],
+    deps = [
+        ":box",
+        ":box_builder",
+        ":constants",
+        ":parse",
+        ":test_utils",
+        ":utils",
+        "//uuid",
+        "@abseil-cpp//absl/numeric:int128",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "convert_json",
+    srcs = ["convert_json.cc"],
+    hdrs = ["convert_json.h"],
+    deps = [
+        ":box",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@libcppbor",
+        "@nlohmann_json//:json",
+    ],
+)
diff --git a/jumbf/README.md b/jumbf/README.md
new file mode 100644
index 0000000..98a041d
--- /dev/null
+++ b/jumbf/README.md
@@ -0,0 +1,77 @@
+# JUMBF Parser
+
+This is a basic parser for the binary format described in ISO/IEC 19566-5. It is
+intended to be a general-purpose library, but was created specifically for C2PA,
+and only the features specifically needed for that use case have been developed
+or tested.
+
+## Supported features
+
+### Parsing
+
+-   [`ConsumeSuperBox(...)`](http://c2pa/jumbf/parse.h): Recursively parses a
+    superbox and all contained boxes into
+    [view-only structs](http://c2pa/jumbf/box.h).
+
+-   Basic box structure: Complete
+
+-   Box file format (Annex A): Complete
+
+-   Content Types (Annex B): Partial, only supports:
+
+    -   JSON (B.4)
+    -   Embedded file (B.6)
+    -   CBOR (B.7)
+    -   C2PA Salt (Not defined by JUMBF, see C2PA 2.0 section 8.3.1.3)
+
+    Other content types can be parsed, but will be returned as `UnknownBox`, and
+    callers are responsible for interpreting the TBox value and handling the raw
+    bytes appropriately.
+
+-   References and requests (Annex C): Partial, only supports:
+
+    -   Lookup of boxes by URI or path (C.2)
+
+## Serialization
+
+-   [`SuperBoxBuilder`](http://c2pa/jumbf/box_builder.h): Stateful builder for
+    creating superboxes.
+
+-   Basic box / superbox structure: Complete
+
+-   Box file format (Annex A): Complete except for the following:
+
+    -   Description box numerical IDs
+
+    -   Description box hashes
+
+-   Content Types (Annex B): All supported but callers are responsible for
+    assembling raw payload and using the correct TBox value.
+
+## Validation
+
+This library is intentionally very permissive. It only validates box lengths and
+the presence of description and content boxes within super boxes.
+
+Callers are responsible for checking that content box types align with those
+declared in description boxes.
+
+## Super Box naming convention
+
+The standard, somewhat confusingly, uses the term "JUMBF box" to refer to a
+"superbox that shall contain exactly one JUMBF Description Box followed by one
+or more JUMBF Content Boxes and at most one Padding Box." However, there are no
+mentions anywhere in the standard of "superboxes" that are not "JUMBF Boxes".
+So, for the sake of clarity, this library uses the term "Super Box" (in code as
+"SuperBox" or "super_box") in place of the term "JUMBF box" as defined by the
+standard.
+
+## Directory structure
+
+The public API is contained in the top-level directory, c2pa/jumbf. Internal
+parsing functions are located in the
+["internal" subdirectory](http://c2pa/jumbf/internal).
+
+As a general rule, recursive parsing and assembling of the output format happens
+in the top-level directory, while functions in the internal subdirectory handle
+consuming the raw bytes.
diff --git a/jumbf/box.h b/jumbf/box.h
new file mode 100644
index 0000000..2e9f795
--- /dev/null
+++ b/jumbf/box.h
@@ -0,0 +1,144 @@
+// 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.
+//
+
+// Types to represent deserialized JUMBF boxes, as defined by ISO/IEC 19566-5.
+#ifndef THIRD_PARTY_CREDENTIO_JUMBF_BOX_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_BOX_H_
+
+#include <cstdint>
+#include <optional>
+#include <variant>
+#include <vector>
+
+#include "absl/strings/string_view.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+struct ContentBox;
+
+struct UnknownBox {
+  uint32_t type;
+  absl::string_view payload;
+
+  bool operator==(const UnknownBox&) const = default;
+};
+
+// See section A.3
+struct DescriptionBox {
+  // The type UUID, referred to simply as "type" in section A.3, not to be
+  // confused with the 32 bit box type described in section 4.3 (all description
+  // boxes must have box type "jumd").
+  credentio::Uuid type_uuid;
+
+  bool requestable = false;
+  std::optional<absl::string_view> label;
+  std::optional<uint32_t> id;
+  // The SHA-256 hash of the associated content boxes. In identical byte order
+  // to the serialized form as defined in the standard (FIPS PUB 180-4). This
+  // value is taken directly from the serialized data and has not been validated
+  // in any way.
+  std::optional<absl::string_view> unvalidated_hash;
+  std::vector<ContentBox> private_content;
+
+  bool operator==(const DescriptionBox&) const = default;
+};
+
+// See section A.2, also see note on naming conventions in README.md
+struct SuperBox {
+  DescriptionBox description;
+  std::vector<ContentBox> contents;
+  absl::string_view raw_bytes;
+
+  bool operator==(const SuperBox&) const = default;
+};
+
+// See section B.7
+struct CborBox {
+  absl::string_view payload;
+
+  bool operator==(const CborBox&) const = default;
+};
+
+// See section B.6.2
+struct EmbeddedFileDescriptionBox {
+  absl::string_view media_type;
+  bool external = false;
+  std::optional<absl::string_view> file_name;
+
+  bool operator==(const EmbeddedFileDescriptionBox&) const = default;
+};
+
+// See section B.6.3
+struct BinaryDataBox {
+  absl::string_view payload;
+
+  bool operator==(const BinaryDataBox&) const = default;
+};
+
+// A super box that was not parsed because the recursion limit was reached.
+struct SerializedSuperbox {
+  // The entire serialized super box.
+  absl::string_view serialized;
+
+  bool operator==(const SerializedSuperbox&) const = default;
+};
+
+// See C2PA 2.0 section 8.3.1.3
+struct C2paSaltBox {
+  absl::string_view salt;
+
+  bool operator==(const C2paSaltBox&) const = default;
+};
+
+struct JsonBox {
+  absl::string_view payload;
+
+  bool operator==(const JsonBox&) const = default;
+};
+
+struct UuidBox {
+  absl::string_view payload;
+
+  bool operator==(const UuidBox&) const = default;
+};
+
+struct ContentBox {
+  absl::string_view raw_bytes;
+
+  std::variant<UnknownBox, CborBox, SerializedSuperbox, SuperBox,
+               EmbeddedFileDescriptionBox, BinaryDataBox, C2paSaltBox, JsonBox,
+               UuidBox>
+      payload;
+
+  bool operator==(const ContentBox&) const = default;
+
+  template <typename ExpectedBox>
+  bool Holds() const {
+    return std::holds_alternative<ExpectedBox>(payload);
+  }
+
+  // Retrieves the payload of type `DesiredBox`. Crashes if this content box
+  // does not contain that type. Use `Holds<ExpectedBox>()` above to check the
+  // contained type first to guarantee safety.
+  template <typename DesiredBox>
+  const DesiredBox& Get() const {
+    return std::get<DesiredBox>(payload);
+  }
+};
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_BOX_H_
diff --git a/jumbf/box_builder.cc b/jumbf/box_builder.cc
new file mode 100644
index 0000000..ea90401
--- /dev/null
+++ b/jumbf/box_builder.cc
@@ -0,0 +1,143 @@
+// 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 "jumbf/box_builder.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/algorithm/container.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_join.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "jumbf/constants.h"
+#include "jumbf/encode_params.h"
+#include "jumbf/internal/encode.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+SuperBoxBuilder::SuperBoxBuilder(credentio::Uuid type_uuid,
+                                 DescriptionBoxOptions description_options)
+    : type_uuid_(std::move(type_uuid)),
+      description_label_(description_options.label),
+      description_options_(std::move(description_options)) {
+  if (description_label_.has_value()) {
+    description_label_->Flatten();
+  }
+}
+
+absl::Status SuperBoxBuilder::AssertStateIn(
+    absl::Span<const SuperBoxBuildingState> want) const {
+  if (absl::c_linear_search(want, state_)) {
+    return absl::OkStatus();
+  }
+
+  std::string want_str;
+  if (want.size() == 1) {
+    want_str = absl::StrCat(want[0]);
+  } else {
+    want_str = absl::StrCat("one of [", absl::StrJoin(want, ", "), "]");
+  }
+  return absl::FailedPreconditionError(
+      absl::StrCat("builder in invalid state for operation: got: ", state_,
+                   ", want: ", want_str));
+}
+
+void SuperBoxBuilder::StateTransition(
+    absl::Span<const SuperBoxBuildingState> from, SuperBoxBuildingState to) {
+  if (absl::c_linear_search(from, state_)) {
+    state_ = to;
+  }
+}
+
+absl::Status SuperBoxBuilder::AddPadding(size_t len) {
+  ABSL_RETURN_IF_ERROR(AssertStateIn({SuperBoxBuildingState::kEmpty}));
+  StateTransition({SuperBoxBuildingState::kEmpty},
+                  SuperBoxBuildingState::kContainsPadding);
+  return EncodePaddingBox(len, &writer_);
+}
+
+absl::Status SuperBoxBuilder::AddChild(SuperBoxBuilder&& child) {
+  ABSL_RETURN_IF_ERROR(AssertStateIn(
+      {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding,
+       SuperBoxBuildingState::kContainsContent}));
+  StateTransition(
+      {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding},
+      SuperBoxBuildingState::kContainsContent);
+
+  ABSL_ASSIGN_OR_RETURN(auto serialized_child, std::move(child).Finalize());
+  return AddChild(std::move(serialized_child));
+};
+
+absl::Status SuperBoxBuilder::AddContent(uint32_t tbox, absl::Cord payload) {
+  ABSL_RETURN_IF_ERROR(AssertStateIn(
+      {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding,
+       SuperBoxBuildingState::kContainsContent}));
+  StateTransition(
+      {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding},
+      SuperBoxBuildingState::kContainsContent);
+  return EncodeContentBox(tbox, std::move(payload), &writer_);
+}
+
+absl::Status SuperBoxBuilder::AddChild(BuiltSuperBox&& serialized_superbox) {
+  StateTransition(
+      {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding},
+      SuperBoxBuildingState::kContainsContent);
+  return EncodeSerializedSuperBox(std::move(serialized_superbox).AsCord(),
+                                  &writer_);
+}
+
+absl::Status SuperBoxBuilder::AddChild(absl::Cord serialized_superbox) {
+  StateTransition(
+      {SuperBoxBuildingState::kEmpty, SuperBoxBuildingState::kContainsPadding},
+      SuperBoxBuildingState::kContainsContent);
+  return EncodeSerializedSuperBox(std::move(serialized_superbox), &writer_);
+}
+
+absl::StatusOr<BuiltSuperBox> SuperBoxBuilder::Finalize() && {
+  // JUMBF requires at least one content box.
+  ABSL_RETURN_IF_ERROR(
+      AssertStateIn({SuperBoxBuildingState::kContainsContent}));
+
+  ABSL_RETURN_IF_ERROR(EncodeDescriptionBox(
+      std::move(type_uuid_), std::move(description_options_), &writer_));
+  ABSL_RETURN_IF_ERROR(EncodeBoxHeader(writer_.pos(), kSuperBoxType, &writer_));
+  if (!writer_.Close()) {
+    return writer_.status();
+  }
+
+  std::optional<std::string> description_label;
+  if (description_label_.has_value()) {
+    description_label = std::string(*description_label_);
+  }
+  auto result =
+      BuiltSuperBox(std::move(writer_.dest()), std::move(description_label));
+
+  // Clear out everything to Builder in a reasonable state post-move:
+  state_ = SuperBoxBuildingState::kEmpty;
+  writer_.Reset();
+  return result;
+}
+
+}  // namespace jumbf
diff --git a/jumbf/box_builder.h b/jumbf/box_builder.h
new file mode 100644
index 0000000..003f1d4
--- /dev/null
+++ b/jumbf/box_builder.h
@@ -0,0 +1,141 @@
+// 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.
+//
+
+// SuperBoxEncoder: Stateful JUMBF encoder.
+//
+// This class tracks box lengths to correctly write headers. See
+// c2pa/jumbf/box_builder_test.cc for usage
+// examples.
+
+#ifndef THIRD_PARTY_CREDENTIO_JUMBF_BOX_BUILDER_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_BOX_BUILDER_H_
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "jumbf/encode_params.h"
+#include "riegeli/bytes/cord_backward_writer.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+enum class SuperBoxBuildingState {
+  kEmpty,
+  kContainsPadding,
+  kContainsContent,
+};
+
+class BuiltSuperBox;
+
+class SuperBoxBuilder {
+ public:
+  SuperBoxBuilder(credentio::Uuid type_uuid,
+                  DescriptionBoxOptions description_options);
+
+  // Adds a padding box to the superbox. May only be called on an empty encoder.
+  absl::Status AddPadding(size_t len);
+
+  // Adds a child superbox as a content box of this superbox. New boxes are
+  // added to the front of this box's content.
+  absl::Status AddChild(SuperBoxBuilder&& child);
+  // Add a child superbox as a content box of this superbox. New boxes are
+  // added to the front of this box's content.
+  absl::Status AddChild(BuiltSuperBox&& serialized_superbox);
+  // Add a child superbox as a content box of this superbox. New boxes are
+  // added to the front of this box's content.
+  absl::Status AddChild(absl::Cord serialized_superbox);
+  // Adds a content box.  New boxes are added to the front of this box's
+  // content.
+  absl::Status AddContent(uint32_t tbox, absl::Cord payload);
+
+  // Finish writing the box. This consumes the builder, use
+  // `std::move(builder).Finalize()`;
+  absl::StatusOr<BuiltSuperBox> Finalize() &&;
+
+ private:
+  absl::Status AssertStateIn(
+      absl::Span<const SuperBoxBuildingState> want) const;
+
+  void StateTransition(absl::Span<const SuperBoxBuildingState> from,
+                       SuperBoxBuildingState to);
+
+  credentio::Uuid type_uuid_;
+  // Need to cache the underlying `absl::Cord` for the label, because the
+  // `DescriptionBoxOptions` gets consumed by `Finalize()`.
+  std::optional<absl::Cord> description_label_;
+  DescriptionBoxOptions description_options_;
+  SuperBoxBuildingState state_ = SuperBoxBuildingState::kEmpty;
+  riegeli::CordBackwardWriter<absl::Cord> writer_;
+};
+
+class BuiltSuperBox {
+ public:
+  BuiltSuperBox() = default;
+
+  std::optional<absl::string_view> description_label() const {
+    return description_label_;
+  }
+
+  // Peeks at the underlying cord. Less efficient than `AsCord()` if the value
+  // is to be stored elsewhere.
+  const absl::Cord& Peek() const { return rep_; }
+  // Moves the underlying cord out of this object. Use
+  // `std::move(box).AsCord()`.
+  absl::Cord AsCord() && {
+    description_label_ = std::nullopt;
+    return std::move(rep_);
+  }
+
+ private:
+  friend absl::StatusOr<BuiltSuperBox> SuperBoxBuilder::Finalize() &&;
+
+  explicit BuiltSuperBox(absl::Cord&& rep,
+                         std::optional<std::string> description_label)
+      : rep_(std::move(rep)),
+        description_label_(std::move(description_label)) {}
+  absl::Cord rep_;
+  std::optional<std::string> description_label_;
+};
+
+template <typename Sink>
+void AbslStringify(Sink& sink, SuperBoxBuildingState state) {
+  switch (state) {
+    case SuperBoxBuildingState::kEmpty:
+      sink.Append("EMPTY");
+      return;
+    case jumbf::SuperBoxBuildingState::kContainsPadding:
+      sink.Append("CONTAINS_PADDING");
+      return;
+    case jumbf::SuperBoxBuildingState::kContainsContent:
+      sink.Append("CONTAINS_CONTENT");
+      return;
+  };
+  LOG(DFATAL) << "Fell through end of exhaustive switch statement.";
+  sink.Append(absl::StrCat("UNKNOWN_STATE_", static_cast<int>(state)));
+}
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_BOX_BUILDER_H_
diff --git a/jumbf/box_builder_test.cc b/jumbf/box_builder_test.cc
new file mode 100644
index 0000000..fbe0f5f
--- /dev/null
+++ b/jumbf/box_builder_test.cc
@@ -0,0 +1,231 @@
+// 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 "jumbf/box_builder.h"
+
+#include <cassert>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/constants.h"
+#include "jumbf/parse.h"
+#include "jumbf/test_utils.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::EndsWith;
+using ::testing::Eq;
+using ::testing::Optional;
+
+TEST(BoxBuilderTest, ContentBox) {
+  jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {});
+  ABSL_ASSERT_OK(builder.AddContent(
+      jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR")));
+
+  auto built_box_or = std::move(builder).Finalize();
+  ABSL_ASSERT_OK(built_box_or);
+  auto built_box = std::move(*built_box_or);
+  absl::Cord serialized = std::move(built_box).AsCord();
+  absl::string_view serialized_view = serialized.Flatten();
+  auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1);
+  ABSL_ASSERT_OK(super_box_or);
+  SuperBox super_box = std::move(*super_box_or);
+  EXPECT_EQ(super_box.description.type_uuid, kCborBoxTypeUuid);
+  ASSERT_EQ(super_box.contents.size(), 1);
+  ASSERT_TRUE(super_box.contents[0].Holds<CborBox>());
+  EXPECT_EQ(super_box.contents[0].Get<CborBox>().payload, "this is not CBOR");
+}
+
+TEST(BoxBuilderTest, InspectLabelAfterFinalize) {
+  jumbf::SuperBoxBuilder with_label(jumbf::kCborBoxTypeUuid,
+                                    {.label = CordFromConstexpr("boxLabel")});
+  ABSL_ASSERT_OK(with_label.AddContent(
+      jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR")));
+  auto box_or = std::move(with_label).Finalize();
+  ABSL_ASSERT_OK(box_or);
+  jumbf::BuiltSuperBox box = std::move(*box_or);
+
+  EXPECT_THAT(box.description_label(), Optional(Eq("boxLabel")));
+}
+
+TEST(BoxBuilderTest, AccessNonexistentLabelAfterFinalize) {
+  jumbf::SuperBoxBuilder with_label(jumbf::kCborBoxTypeUuid, {});
+  ABSL_ASSERT_OK(with_label.AddContent(
+      jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR")));
+  auto box_or = std::move(with_label).Finalize();
+  ABSL_ASSERT_OK(box_or);
+  jumbf::BuiltSuperBox box = std::move(*box_or);
+
+  EXPECT_THAT(box.description_label(), Eq(std::nullopt));
+}
+
+TEST(BoxBuilderTest, PaddingAndContentBox) {
+  jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {});
+  ABSL_ASSERT_OK(builder.AddPadding(20));
+  ABSL_ASSERT_OK(builder.AddContent(
+      jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR")));
+  auto box_or = std::move(builder).Finalize();
+  ABSL_ASSERT_OK(box_or);
+  jumbf::BuiltSuperBox box = std::move(*box_or);
+
+  EXPECT_THAT(box.Peek(), EndsWith(std::string(20, 0)));
+
+  absl::Cord serialized = std::move(box).AsCord();
+  absl::string_view serialized_view = serialized.Flatten();
+  auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1);
+  ABSL_ASSERT_OK(super_box_or);
+  SuperBox super_box = std::move(*super_box_or);
+  EXPECT_EQ(super_box.description.type_uuid, kCborBoxTypeUuid);
+  ASSERT_EQ(super_box.contents.size(), 1);
+  ASSERT_TRUE(super_box.contents[0].Holds<CborBox>());
+  EXPECT_EQ(super_box.contents[0].Get<CborBox>().payload, "this is not CBOR");
+}
+
+TEST(BoxBuilderTest, MultipleContentBox) {
+  jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {});
+  ABSL_ASSERT_OK(builder.AddContent(
+      jumbf::kCborBoxType, jumbf::CordFromConstexpr("this is not CBOR")));
+  ABSL_ASSERT_OK(builder.AddContent(kBinaryDataBoxType,
+                                    jumbf::CordFromConstexpr("I am data!")));
+
+  auto built_box_or = std::move(builder).Finalize();
+  ABSL_ASSERT_OK(built_box_or);
+  auto built_box = std::move(*built_box_or);
+  absl::Cord serialized = std::move(built_box).AsCord();
+  absl::string_view serialized_view = serialized.Flatten();
+  auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1);
+  ABSL_ASSERT_OK(super_box_or);
+  SuperBox super_box = std::move(*super_box_or);
+  EXPECT_EQ(super_box.description.type_uuid, kCborBoxTypeUuid);
+  ASSERT_EQ(super_box.contents.size(), 2);
+  ASSERT_TRUE(super_box.contents[0].Holds<BinaryDataBox>());
+  EXPECT_EQ(super_box.contents[0].Get<BinaryDataBox>().payload, "I am data!");
+  ASSERT_TRUE(super_box.contents[1].Holds<CborBox>());
+  EXPECT_EQ(super_box.contents[1].Get<CborBox>().payload, "this is not CBOR");
+}
+
+TEST(BoxBuilderTest, NestedSuperBox) {
+  credentio::Uuid uuid1(0xfeedbabedefec8ed, 0xdeadbeeff00dbabe);
+  credentio::Uuid uuid2 = jumbf::kCborBoxTypeUuid;
+  credentio::Uuid uuid3(0xdeadbeeff00dbabe, 0xfeedbabedefec8ed);
+
+  jumbf::SuperBoxBuilder first_inner_builder(uuid1, {});
+  ABSL_ASSERT_OK(first_inner_builder.AddContent(
+      jumbf::kCborBoxType, jumbf::CordFromConstexpr("I go in third")));
+  auto first_inner_box_or = std::move(first_inner_builder).Finalize();
+  ABSL_ASSERT_OK(first_inner_box_or);
+  jumbf::BuiltSuperBox first_inner_box = std::move(*first_inner_box_or);
+  jumbf::SuperBoxBuilder second_inner_builder(uuid2, {});
+  ABSL_ASSERT_OK(second_inner_builder.AddContent(
+      jumbf::kCborBoxType, jumbf::CordFromConstexpr("I go in second")));
+  auto second_inner_box_or = std::move(second_inner_builder).Finalize();
+  ABSL_ASSERT_OK(second_inner_box_or);
+  BuiltSuperBox second_inner_box = std::move(*second_inner_box_or);
+  jumbf::SuperBoxBuilder third_inner_builder(uuid3, {});
+  ABSL_ASSERT_OK(third_inner_builder.AddContent(
+      kBinaryDataBoxType, CordFromConstexpr("I go in first")));
+  auto third_inner_box_or = std::move(third_inner_builder).Finalize();
+  ABSL_ASSERT_OK(third_inner_box_or);
+  BuiltSuperBox third_inner_box = std::move(*third_inner_box_or);
+
+  jumbf::SuperBoxBuilder outer(
+      credentio::Uuid(0xdefec8edb44df00d, 0xfeedbabef00dbabe), {});
+  ABSL_ASSERT_OK(outer.AddChild(std::move(third_inner_box)));
+  ABSL_ASSERT_OK(outer.AddChild(std::move(second_inner_box)));
+  ABSL_ASSERT_OK(outer.AddChild(std::move(first_inner_box)));
+
+  auto built_box_or = std::move(outer).Finalize();
+  ABSL_ASSERT_OK(built_box_or);
+  auto built_box = std::move(*built_box_or);
+  absl::Cord serialized = std::move(built_box).AsCord();
+  absl::string_view serialized_view = serialized.Flatten();
+  auto super_box_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1);
+  ABSL_ASSERT_OK(super_box_or);
+  SuperBox super_box = std::move(*super_box_or);
+
+  EXPECT_EQ(super_box.description.type_uuid,
+            credentio::Uuid(0xdefec8edb44df00d, 0xfeedbabef00dbabe));
+  ASSERT_EQ(super_box.contents.size(), 3);
+
+  // Box 0: third_inner_box
+  ASSERT_TRUE(super_box.contents[0].Holds<SuperBox>());
+  const auto& nested0 = super_box.contents[0].Get<SuperBox>();
+  EXPECT_EQ(nested0.description.type_uuid, uuid1);
+  ASSERT_EQ(nested0.contents.size(), 1);
+  ASSERT_TRUE(nested0.contents[0].Holds<CborBox>());
+  EXPECT_EQ(nested0.contents[0].Get<CborBox>().payload, "I go in third");
+
+  // Box 1: second_inner_box
+  ASSERT_TRUE(super_box.contents[1].Holds<SuperBox>());
+  const auto& nested1 = super_box.contents[1].Get<SuperBox>();
+  EXPECT_EQ(nested1.description.type_uuid, uuid2);
+  ASSERT_EQ(nested1.contents.size(), 1);
+  ASSERT_TRUE(nested1.contents[0].Holds<CborBox>());
+  EXPECT_EQ(nested1.contents[0].Get<CborBox>().payload, "I go in second");
+
+  // Box 2: first_inner_box
+  ASSERT_TRUE(super_box.contents[2].Holds<SuperBox>());
+  const auto& nested2 = super_box.contents[2].Get<SuperBox>();
+  EXPECT_EQ(nested2.description.type_uuid, uuid3);
+  ASSERT_EQ(nested2.contents.size(), 1);
+  ASSERT_TRUE(nested2.contents[0].Holds<BinaryDataBox>());
+  EXPECT_EQ(nested2.contents[0].Get<BinaryDataBox>().payload, "I go in first");
+}
+
+TEST(BoxBuilderTest, EmptyBoxFails) {
+  jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {});
+
+  EXPECT_THAT(std::move(builder).Finalize(),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       "builder in invalid state for operation: got: EMPTY, "
+                       "want: CONTAINS_CONTENT"));
+}
+
+TEST(BoxBuilderTest, MultiplePaddingFails) {
+  jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {});
+  ABSL_ASSERT_OK(builder.AddPadding(20));
+
+  EXPECT_THAT(builder.AddPadding(22),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       "builder in invalid state for operation: got: "
+                       "CONTAINS_PADDING, want: EMPTY"));
+}
+
+TEST(BoxBuilderTest, PaddingAfterContentFails) {
+  jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {});
+  ABSL_ASSERT_OK(builder.AddContent(
+      kBinaryDataBoxType, CordFromConstexpr("bbbbbbbbbbbbbbbbbbbbbb")));
+
+  EXPECT_THAT(builder.AddPadding(22),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       "builder in invalid state for operation: got: "
+                       "CONTAINS_CONTENT, want: EMPTY"));
+}
+
+}  // namespace
+
+}  // namespace jumbf
diff --git a/jumbf/constants.h b/jumbf/constants.h
new file mode 100644
index 0000000..c3c6de7
--- /dev/null
+++ b/jumbf/constants.h
@@ -0,0 +1,73 @@
+// 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.
+//
+
+// Constants for use in parsing JUMBF data. All values from ISO/IEC 19566-5.
+//
+// Note that all values here are in machine endianness. The standard calls for
+// them to be serialized in big-endian order.
+#ifndef THIRD_PARTY_CREDENTIO_JUMBF_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_CONSTANTS_H_
+
+#include <cstdint>
+
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+// Toggle masks for description boxes. (See A.3)
+inline constexpr uint8_t kDescriptionToggleEmpty = 0b0000'0000;
+inline constexpr uint8_t kDescriptionToggleRequestable = 0b0000'0001;
+inline constexpr uint8_t kDescriptionToggleLabelPresent = 0b0000'0010;
+inline constexpr uint8_t kDescriptionToggleIdPresent = 0b0000'0100;
+inline constexpr uint8_t kDescriptionToggleHashPresent = 0b0000'1000;
+inline constexpr uint8_t kDescriptionTogglePrivatePresent = 0b0001'0000;
+
+// Toggle masks for embedded file description boxes (EFDB). (See B.6.2)
+inline constexpr uint8_t kEfdbToggleFileNamePresent = 0b0000'0001;
+inline constexpr uint8_t kEfdbToggleExternal = 0b0000'0010;
+
+// Box types (TBox values). These are the values that apply to individual boxes,
+// not description boxes. (See 4.3, A.1 and B)
+//
+// "jumb" - Superbox (also referred to in the standard as simply "box")
+inline constexpr uint32_t kSuperBoxType = 0x6a75'6d62;
+// "jumd" - Description box
+inline constexpr uint32_t kDescriptionBoxType = 0x6a75'6d64;
+// "cbor" - CBOR content box
+inline constexpr uint32_t kCborBoxType = 0x6362'6f72;
+// "free" - Padding box
+inline constexpr uint32_t kPaddingBoxType = 0x6672'6565;
+// "bfdb" - Embedded file description box
+inline constexpr uint32_t kEmbeddedFileDescriptionBoxType = 0x6266'6462;
+// "bidb" - Binary data box
+inline constexpr uint32_t kBinaryDataBoxType = 0x6269'6462;
+// "priv" - Private content super box
+inline constexpr uint32_t kPrivateContentBoxType = 0x7072'6976;
+// "c2sh" - C2PA salt box, see C2PA 2.0 section 8.3.1.3
+inline constexpr uint32_t kC2paSaltBoxType = 0x6332'7368;
+// "json" - JSON content box
+inline constexpr uint32_t kJsonBoxType = 0x6a73'6f6e;
+// "uuid" - UUID content box
+inline constexpr uint32_t kUuidBoxType = 0x7575'6964;
+
+inline constexpr auto kCborBoxTypeUuid =
+    credentio::Uuid::FromStringOrDie("63626F72-0011-0010-8000-00AA00389B71");
+
+inline constexpr auto kC2PARedactionUuid =
+    credentio::Uuid::FromStringOrDie("CAA98EEE-9D4D-F80E-86AD-4DFFCA263973");
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_CONSTANTS_H_
diff --git a/jumbf/convert_json.cc b/jumbf/convert_json.cc
new file mode 100644
index 0000000..650f406
--- /dev/null
+++ b/jumbf/convert_json.cc
@@ -0,0 +1,278 @@
+// 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 "jumbf/convert_json.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "cppbor/cppbor.h"
+#include "cppbor/cppbor_parse.h"
+#include "jumbf/box.h"
+#include "nlohmann/json.hpp"
+#include "nlohmann/json_fwd.hpp"
+
+namespace jumbf {
+namespace {
+
+using Json = ::nlohmann::json;
+
+std::string EncodePrefixedBase64(absl::string_view raw) {
+  return absl::StrCat("b64'", absl::Base64Escape(raw), "'");
+}
+
+std::string EncodeCbor(cppbor::Item* item) {
+  std::vector<uint8_t> buf;
+  buf.resize(item->encodedSize());
+  item->encode(buf.data(), buf.data() + buf.size());
+  return std::string(reinterpret_cast<const char*>(buf.data()), buf.size());
+}
+
+cppbor::Map BstrToB64(cppbor::Map* map);
+
+std::unique_ptr<cppbor::Item> UnwrapTag(std::unique_ptr<cppbor::Item> item) {
+  while (item->asSemanticTag() != nullptr) {
+    if (auto t = item->asTstr(); t != nullptr) return t->clone();
+    if (auto u = item->asUint(); u != nullptr) return u->clone();
+    if (auto i = item->asInt(); i != nullptr) return i->clone();
+    if (auto b = item->asBool(); b != nullptr) return b->clone();
+    if (auto f = item->asFloat(); f != nullptr) return f->clone();
+    if (auto d = item->asDouble(); d != nullptr) return d->clone();
+    if (auto s = item->asSimple(); s != nullptr) return s->clone();
+    break;
+  }
+  return item;
+}
+
+// Recursively replaces all Bstr with base64-encoded Tstr.
+cppbor::Array BstrToB64(cppbor::Array* array) {
+  cppbor::Array new_array;
+  for (auto& item_ref : *array) {
+    auto item = UnwrapTag(std::move(item_ref));
+    if (auto s = item->asBstr(); s != nullptr) {
+      absl::string_view sv(reinterpret_cast<const char*>(s->value().data()),
+                           s->value().size());
+      new_array.add(cppbor::Tstr(EncodePrefixedBase64(sv)));
+    } else if (auto m = item->asMap(); m != nullptr) {
+      new_array.add(BstrToB64(m));
+    } else if (auto a = item->asArray(); a != nullptr) {
+      new_array.add(BstrToB64(a));
+    } else {
+      new_array.add(std::move(item));
+    }
+  }
+  return new_array;
+}
+
+// Recursively replaces all Bstr with base64-encoded Tstr.
+cppbor::Map BstrToB64(cppbor::Map* map) {
+  cppbor::Map new_map;
+  for (auto& [key_ref, value_ref] : *map) {
+    auto key = UnwrapTag(std::move(key_ref));
+    auto value = UnwrapTag(std::move(value_ref));
+    if (auto s = value->asBstr(); s != nullptr) {
+      absl::string_view sv(reinterpret_cast<const char*>(s->value().data()),
+                           s->value().size());
+      new_map.add(std::move(key), cppbor::Tstr(EncodePrefixedBase64(sv)));
+    } else if (auto m = value->asMap(); m != nullptr) {
+      new_map.add(std::move(key), BstrToB64(m));
+    } else if (auto v = value->asArray(); v != nullptr) {
+      cppbor::Array new_array = BstrToB64(v);
+      new_map.add(std::move(key), std::move(new_array));
+    } else {
+      new_map.add(std::move(key), std::move(value));
+    }
+  }
+  return new_map;
+}
+
+// Recursively replaces all Bstr with base64-encoded Tstr. Supports only arrays
+// and maps.
+absl::StatusOr<std::string> BstrToB64(absl::string_view raw) {
+  auto [item, new_position, error] =
+      cppbor::parse(reinterpret_cast<const uint8_t*>(raw.data()), raw.length());
+  if (!error.empty()) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("CBOR parsing failed: ", error));
+  }
+  if (new_position !=
+      reinterpret_cast<const uint8_t*>(raw.data() + raw.length())) {
+    return absl::InvalidArgumentError(
+        "Trailing bytes after the parsed CBOR item");
+  }
+
+  if (auto map = item->asMap(); map != nullptr) {
+    auto new_map = BstrToB64(map);
+    return EncodeCbor(&new_map);
+  } else if (auto array = item->asArray(); array != nullptr) {
+    auto new_array = BstrToB64(array);
+    return EncodeCbor(&new_array);
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("Input CBOR item is ", item->type(),
+                   "; expected array (128) or map (160)."));
+}
+
+class AllExceptionParser
+    : public nlohmann::detail::json_sax_dom_parser<
+          Json, nlohmann::detail::iterator_input_adapter<const char*>> {
+ public:
+  explicit AllExceptionParser(Json& j)
+      : nlohmann::detail::json_sax_dom_parser<
+            Json, nlohmann::detail::iterator_input_adapter<const char*>>(
+            j, false) {}
+
+  bool parse_error(std::size_t position, absl::string_view last_token,
+                   const Json::exception& ex) {
+    errors_.push_back(absl::StrFormat("%s", ex.what()));
+    return false;
+  }
+
+  std::vector<std::string> errors() { return errors_; }
+
+ private:
+  std::vector<std::string> errors_;
+};
+
+}  // namespace
+
+void to_json(Json& j, const DescriptionBox& box) {
+  if (box.label.has_value()) {
+    j["label"] = box.label.value();
+  }
+  if (box.id.has_value()) {
+    j["id"] = box.id.value();
+  }
+  if (box.unvalidated_hash.has_value()) {
+    j["hash"] = box.unvalidated_hash.value();
+  }
+  j["uuid"] = box.type_uuid.ToString();
+  j["requestable"] = box.requestable;
+
+  for (const auto& box : box.private_content) {
+    j["private_content"].push_back(box);
+  }
+
+  j["_meta"]["_type"] = "description";
+}
+
+void to_json(Json& j, const SuperBox& box) {
+  j["description"] = box.description;
+
+  for (const auto& box : box.contents) {
+    j["contents"].push_back(box);
+  }
+
+  j["_meta"]["_type"] = "super";
+}
+
+void to_json(Json& j, const UnknownBox& box) {
+  j["type"] = box.type;
+
+  j["_meta"]["_type"] = "unknown";
+  j["_meta"]["_payload_size"] = box.payload.size();
+}
+
+void to_json(Json& j, const CborBox& box) {
+  j["_meta"]["_type"] = "cbor";
+  j["_meta"]["_payload_size"] = box.payload.size();
+
+  absl::StatusOr<std::string> encoded = BstrToB64(box.payload);
+  if (!encoded.ok()) {
+    j["_meta"]["_error"] = encoded.status().ToString();
+    return;
+  }
+
+  AllExceptionParser parser(j["cbor"]);
+  auto ia = nlohmann::detail::input_adapter(*encoded);
+  nlohmann::detail::binary_reader<Json, decltype(ia), AllExceptionParser>
+      reader(std::move(ia), Json::input_format_t::cbor);
+  reader.sax_parse(Json::input_format_t::cbor, &parser, /*strict=*/true,
+                   /*tag_handler=*/Json::cbor_tag_handler_t::ignore);
+  if (!parser.errors().empty()) {
+    j["_meta"]["_error"] = parser.errors();
+  }
+}
+
+void to_json(Json& j, const SerializedSuperbox& box) {
+  j["_meta"]["_type"] = "serialized_superbox";
+  j["_meta"]["_serialized_size"] = box.serialized.size();
+}
+
+void to_json(Json& j, const EmbeddedFileDescriptionBox& box) {
+  j["media_type"] = box.media_type;
+  j["external"] = box.external;
+  if (box.file_name.has_value()) {
+    j["file_name"] = box.file_name.value();
+  }
+
+  j["_meta"]["_type"] = "embedded_file_description";
+}
+
+void to_json(Json& j, const BinaryDataBox& box) {
+  j["_meta"]["_type"] = "binary";
+  j["_meta"]["_payload_size"] = box.payload.size();
+}
+
+void to_json(Json& j, const C2paSaltBox& box) {
+  j["_meta"]["_type"] = "c2pa_salt";
+  j["_meta"]["_salt_size"] = box.salt.size();
+}
+
+void to_json(Json& j, const JsonBox& box) {
+  AllExceptionParser parser(j["json"]);
+  Json::sax_parse(box.payload, &parser, Json::input_format_t::json);
+
+  if (!parser.errors().empty()) {
+    j["_meta"]["_error"] = parser.errors();
+  }
+  j["_meta"]["_type"] = "json";
+  j["_meta"]["_payload_size"] = box.payload.size();
+}
+
+void to_json(Json& j, const ContentBox& box) {
+  if (box.Holds<jumbf::SuperBox>()) {
+    j = box.Get<jumbf::SuperBox>();
+  } else if (box.Holds<jumbf::UnknownBox>()) {
+    j = box.Get<jumbf::UnknownBox>();
+  } else if (box.Holds<jumbf::CborBox>()) {
+    j = box.Get<jumbf::CborBox>();
+  } else if (box.Holds<jumbf::SerializedSuperbox>()) {
+    j = box.Get<jumbf::SerializedSuperbox>();
+  } else if (box.Holds<jumbf::EmbeddedFileDescriptionBox>()) {
+    j = box.Get<jumbf::EmbeddedFileDescriptionBox>();
+  } else if (box.Holds<jumbf::BinaryDataBox>()) {
+    j = box.Get<jumbf::BinaryDataBox>();
+  } else if (box.Holds<jumbf::C2paSaltBox>()) {
+    j = box.Get<jumbf::C2paSaltBox>();
+  } else if (box.Holds<jumbf::JsonBox>()) {
+    j = box.Get<jumbf::JsonBox>();
+  } else {
+    j["_meta"]["_error"] = "Contents Not Accounted For";
+  }
+}
+
+}  // namespace jumbf
diff --git a/jumbf/convert_json.h b/jumbf/convert_json.h
new file mode 100644
index 0000000..1373ecc
--- /dev/null
+++ b/jumbf/convert_json.h
@@ -0,0 +1,37 @@
+// 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_JUMBF_CONVERT_JSON_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_CONVERT_JSON_H_
+
+#include "jumbf/box.h"
+#include "nlohmann/json_fwd.hpp"
+
+namespace jumbf {
+
+void to_json(nlohmann::json& json, const ContentBox& box);
+void to_json(nlohmann::json& json, const DescriptionBox& box);
+void to_json(nlohmann::json& json, const SuperBox& box);
+void to_json(nlohmann::json& json, const UnknownBox& box);
+void to_json(nlohmann::json& json, const CborBox& box);
+void to_json(nlohmann::json& json, const SerializedSuperbox& box);
+void to_json(nlohmann::json& json, const BinaryDataBox& box);
+void to_json(nlohmann::json& json, const C2paSaltBox& box);
+void to_json(nlohmann::json& json, const JsonBox& box);
+void to_json(nlohmann::json& json, const ContentBox& box);
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_CONVERT_JSON_H_
diff --git a/jumbf/encode_params.h b/jumbf/encode_params.h
new file mode 100644
index 0000000..9ede39f
--- /dev/null
+++ b/jumbf/encode_params.h
@@ -0,0 +1,38 @@
+// 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_JUMBF_ENCODE_PARAMS_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_ENCODE_PARAMS_H_
+
+#include <optional>
+
+#include "absl/strings/cord.h"
+
+namespace jumbf {
+
+// Common options for all encoding functions that write description boxes.
+struct DescriptionBoxOptions {
+  // Whether to set the requestable bit IFF `label` has a value (setting the
+  // requestable bit without providing a label is not possible because it is not
+  // permitted by the standard.)
+  bool requestable_iff_label = true;
+  std::optional<absl::Cord> label = std::nullopt;
+  // Fully-encoded private content.
+  std::optional<absl::Cord> private_content = std::nullopt;
+};
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_ENCODE_PARAMS_H_
diff --git a/jumbf/internal/BUILD b/jumbf/internal/BUILD
new file mode 100644
index 0000000..447a37a
--- /dev/null
+++ b/jumbf/internal/BUILD
@@ -0,0 +1,238 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "encode",
+    srcs = ["encode.cc"],
+    hdrs = ["encode.h"],
+    deps = [
+        ":serialization_utils",
+        "//jumbf:constants",
+        "//jumbf:encode_params",
+        "//uuid",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings:cord",
+        "@riegeli//riegeli/base:types",
+        "@riegeli//riegeli/bytes:backward_writer",
+    ],
+)
+
+cc_test(
+    name = "encode_test",
+    srcs = ["encode_test.cc"],
+    deps = [
+        ":encode",
+        ":serialization_utils",
+        "//jumbf:constants",
+        "//jumbf:encode_params",
+        "//jumbf:test_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/base:chain",
+        "@riegeli//riegeli/base:initializer",
+        "@riegeli//riegeli/bytes:chain_backward_writer",
+        "@riegeli//riegeli/bytes:position_shifting_backward_writer",
+    ],
+)
+
+cc_library(
+    name = "intermediate",
+    hdrs = ["intermediate.h"],
+    deps = [
+        "//uuid",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "consume_box",
+    srcs = ["consume_box.cc"],
+    hdrs = ["consume_box.h"],
+    deps = [
+        ":intermediate",
+        ":serialization_utils",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "parse_super_box",
+    srcs = ["parse_super_box.cc"],
+    hdrs = ["parse_super_box.h"],
+    deps = [
+        ":consume_box",
+        ":intermediate",
+        ":parse_description_box",
+        "//jumbf:constants",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "serialization_utils",
+    srcs = ["serialization_utils.cc"],
+    hdrs = ["serialization_utils.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/base:byte_fill",
+        "@riegeli//riegeli/bytes:backward_writer",
+        "@riegeli//riegeli/endian:endian_reading",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "parse_description_box",
+    srcs = ["parse_description_box.cc"],
+    hdrs = ["parse_description_box.h"],
+    deps = [
+        ":consume_box",
+        ":intermediate",
+        ":serialization_utils",
+        "//jumbf:constants",
+        "//uuid",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "parse_embedded_file_description_box",
+    srcs = ["parse_embedded_file_description_box.cc"],
+    hdrs = ["parse_embedded_file_description_box.h"],
+    deps = [
+        ":intermediate",
+        ":serialization_utils",
+        "//jumbf:box",
+        "//jumbf:constants",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "parse_super_box_test",
+    srcs = ["parse_super_box_test.cc"],
+    deps = [
+        ":intermediate",
+        ":parse_super_box",
+        "//jumbf:constants",
+        "//jumbf:test_utils",
+        "//uuid",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "serialization_utils_test",
+    srcs = ["serialization_utils_test.cc"],
+    deps = [
+        ":serialization_utils",
+        "//jumbf:test_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/base:initializer",
+        "@riegeli//riegeli/bytes:chain_backward_writer",
+        "@riegeli//riegeli/bytes:position_shifting_backward_writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "parse_description_box_test",
+    srcs = ["parse_description_box_test.cc"],
+    deps = [
+        ":intermediate",
+        ":parse_description_box",
+        "//jumbf:constants",
+        "//jumbf:test_utils",
+        "//uuid",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "consume_box_test",
+    srcs = ["consume_box_test.cc"],
+    tags = ["requires-mem:16g"],
+    deps = [
+        ":consume_box",
+        ":intermediate",
+        "//jumbf:test_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "parse_embedded_file_description_box_test",
+    srcs = ["parse_embedded_file_description_box_test.cc"],
+    deps = [
+        ":intermediate",
+        ":parse_embedded_file_description_box",
+        "//jumbf:box",
+        "//jumbf:constants",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
diff --git a/jumbf/internal/consume_box.cc b/jumbf/internal/consume_box.cc
new file mode 100644
index 0000000..b7919b0
--- /dev/null
+++ b/jumbf/internal/consume_box.cc
@@ -0,0 +1,97 @@
+// 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 "jumbf/internal/consume_box.h"
+
+#include <cstddef>
+#include <cstdint>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/internal/intermediate.h"
+#include "jumbf/internal/serialization_utils.h"
+
+namespace jumbf_internal {
+
+absl::StatusOr<IntermediateBox> ConsumeBox(absl::string_view* input,
+                                           bool allow_implicit_length) {
+  absl::string_view temp_input = *input;
+
+  auto lbox = ConsumeInteger<uint32_t>(&temp_input);
+  if (!lbox.ok()) {
+    return absl::Status(
+        lbox.status().code(),
+        absl::StrCat("cannot read LBox value: ", lbox.status().message()));
+  }
+
+  auto tbox = ConsumeInteger<uint32_t>(&temp_input);
+  if (!tbox.ok()) {
+    return absl::Status(
+        tbox.status().code(),
+        absl::StrCat("cannot read TBox value: ", tbox.status().message()));
+  }
+  uint64_t box_length;
+
+  if (*lbox == 0) {
+    if (allow_implicit_length) {
+      box_length = input->length();
+    } else {
+      return absl::InvalidArgumentError(
+          R"(LBox is 0, indicating box extends to end of input, but context does not allow for implicit box lengths; implicit boxes are only allowed if they are the last box in the file, and if they are contained within a super box, that super box must also have an implicit length)");
+    }
+
+  } else if (*lbox == 1) {
+    auto xlbox = ConsumeInteger<uint64_t>(&temp_input);
+    if (!xlbox.ok()) {
+      return absl::Status(
+          xlbox.status().code(),
+          absl::StrCat("cannot read XLBox value: ", xlbox.status().message()));
+    }
+    if (*xlbox < 16) {
+      return absl::InvalidArgumentError(absl::StrCat(
+          "XLBox is ", *xlbox,
+          ", which is less than the number of bytes already consumed by "
+          "this box's header"));
+    }
+    box_length = *xlbox;
+  } else if (*lbox <= 7) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "LBox is ", *lbox, "; LBox values 2-7 are reserved by the standard"));
+  } else {
+    box_length = *lbox;
+  }
+
+  if (box_length > input->length()) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "not enough input bytes for declared box size; have ", input->length(),
+        " input bytes, box declares its size as ", box_length, " bytes"));
+  }
+
+  size_t header_bytes = temp_input.data() - input->data();
+  absl::string_view serialized = input->substr(0, box_length);
+  input->remove_prefix(box_length);
+
+  return IntermediateBox{
+      .payload = temp_input.substr(0, box_length - header_bytes),
+      .serialized = serialized,
+      .type = *tbox,
+      .implicit_length = *lbox == 0,
+  };
+}
+
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/consume_box.h b/jumbf/internal/consume_box.h
new file mode 100644
index 0000000..bb2f1b0
--- /dev/null
+++ b/jumbf/internal/consume_box.h
@@ -0,0 +1,35 @@
+// 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_JUMBF_INTERNAL_CONSUME_BOX_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_CONSUME_BOX_H_
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/internal/intermediate.h"
+
+namespace jumbf_internal {
+
+// Consumes the first box in `input`. Only parses the basic box structure
+// (section 4.3). Only validates box size values. If  an error occurs, `input`
+// will not be modified. `allow_implicit_length` determines if LBox values
+// of 0 are allowed. This value should be set to true only if the current
+// parsing context would allow for an implicit box length.
+absl::StatusOr<IntermediateBox> ConsumeBox(absl::string_view* input,
+                                           bool allow_implicit_length);
+
+}  // namespace jumbf_internal
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_CONSUME_BOX_H_
diff --git a/jumbf/internal/consume_box_test.cc b/jumbf/internal/consume_box_test.cc
new file mode 100644
index 0000000..f8dd440
--- /dev/null
+++ b/jumbf/internal/consume_box_test.cc
@@ -0,0 +1,324 @@
+// 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 "jumbf/internal/consume_box.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/internal/intermediate.h"
+#include "jumbf/test_utils.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace jumbf_internal {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::jumbf::WrapBox;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+
+TEST(ConsumeBoxTest, ConsumeSuccessfulExtraData) {
+  constexpr absl::string_view kExtraData = R"(this is not JUMBF data)";
+  constexpr absl::string_view kPayload = R"(this is the box's payload)";
+
+  const std::string buffer = absl::StrCat(WrapBox(kPayload, 1234), kExtraData);
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              IsOkAndHolds(IntermediateBox{
+                  .payload = kPayload,
+                  .serialized = buffer.substr(0, kPayload.size() + 8),
+                  .type = 1234,
+              }));
+  EXPECT_THAT(input, Eq(kExtraData));
+}
+
+TEST(ConsumeBoxTest, ConsumeSuccessfulTwoBoxes) {
+  constexpr absl::string_view kFirstPayload =
+      R"(this is the first box's payload)";
+  constexpr absl::string_view kSecondPayload =
+      R"(this is the second box's payload)";
+
+  const std::string buffer =
+      absl::StrCat(WrapBox(kFirstPayload, 1234), WrapBox(kSecondPayload, 5678));
+  absl::string_view input = buffer;
+
+  size_t first_box_size = kFirstPayload.size() + 8;
+  size_t second_box_size = kSecondPayload.size() + 8;
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              IsOkAndHolds(IntermediateBox{
+                  .payload = kFirstPayload,
+                  .serialized = buffer.substr(0, first_box_size),
+                  .type = 1234,
+              }));
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              IsOkAndHolds(IntermediateBox{
+                  .payload = kSecondPayload,
+                  .serialized = buffer.substr(first_box_size, second_box_size),
+                  .type = 5678,
+              }));
+  EXPECT_THAT(input, IsEmpty());
+}
+
+TEST(ConsumeBoxTest, ConsumeSuccessful) {
+  constexpr absl::string_view kPayload = R"(this is the box's payload)";
+
+  const std::string buffer = WrapBox(kPayload, 1234);
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              IsOkAndHolds(IntermediateBox{
+                  .payload = kPayload, .serialized = buffer, .type = 1234}));
+  EXPECT_THAT(input, IsEmpty());
+}
+
+TEST(ConsumeBoxTest, EmptyInputFails) {
+  absl::string_view input = "";
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              StatusIs(absl::StatusCode::kOutOfRange,
+                       "cannot read LBox value: not enough input bytes "
+                       "remaining; have 0 need 4"));
+  EXPECT_THAT(input, IsEmpty());
+}
+
+TEST(ConsumeBoxTest, SizeTooLargeFails) {
+  constexpr absl::string_view kPayload = R"(this is the box's payload)";
+
+  std::string buffer = WrapBox(kPayload, 1234);
+  char* buffer_data = buffer.data();
+  // Overwrite size with too large of  value
+  riegeli::WriteBigEndian<uint32_t>(5280, buffer_data);
+  buffer_data += sizeof(uint32_t);
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "not enough input bytes for declared box size; have 33 "
+                       "input bytes, box declares its size as 5280 bytes"));
+  EXPECT_THAT(input, Eq(buffer));
+}
+
+TEST(ConsumeBoxTest, XlBoxSizeSmallPayloadSucceeds) {
+  // Small payload, could have fit without using XLBox but the standard never
+  // states a minimum size to use XLBox.
+  constexpr absl::string_view kFakePayload = "some random data blah blah";
+  constexpr uint32_t kLBox = 1;
+  constexpr uint32_t kTBox = 12345;
+  constexpr uint64_t kXlBox =
+      /*LBox*/ 4 + /*TBox*/ 4 + /*XLBox*/ 8 + kFakePayload.length();
+
+  std::string buffer(kXlBox, 'X');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(kLBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(kTBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint64_t>(kXlBox, cursor);
+  cursor += sizeof(uint64_t);
+  // Safe because size of string was computed to be large enough for
+  // kFakePayload above.
+  memcpy(cursor, kFakePayload.data(), kFakePayload.size());
+
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              IsOkAndHolds(IntermediateBox{
+                  .payload = kFakePayload,
+                  .serialized = buffer,
+                  .type = kTBox,
+                  .implicit_length = false,
+              }));
+  EXPECT_THAT(input, IsEmpty());
+}
+
+TEST(ConsumeBoxTest, XlBoxSizeLargePayloadSucceeds) {
+  // Large payload, could not have fit without using XLBox.
+  const std::string payload(5'000'000'000, 'X');
+  constexpr uint32_t kLBox = 1;
+  constexpr uint32_t kTBox = 12345;
+  const uint64_t XlBox =
+      /*LBox=*/4 + /*TBox=*/4 + /*XLBox=*/8 + payload.length();
+
+  std::string buffer(XlBox, 'X');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(kLBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(kTBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint64_t>(XlBox, cursor);
+  cursor += sizeof(uint64_t);
+  // Safe because size of string was computed to be large enough for
+  // kFakePayload above.
+  memcpy(cursor, payload.data(), payload.size());
+
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              IsOkAndHolds(IntermediateBox{
+                  .payload = payload,
+                  .serialized = buffer,
+                  .type = kTBox,
+                  .implicit_length = false,
+              }));
+  EXPECT_THAT(input, IsEmpty());
+}
+
+TEST(ConsumeBoxTest, XlBoxSizeTooLargeFails) {
+  constexpr absl::string_view kFakePayload = "some random data blah blah";
+  constexpr uint32_t kLBox = 1;
+  constexpr uint32_t kTBox = 12345;
+  constexpr uint64_t kXlBox = 8008888888;
+
+  std::string buffer(
+      /*LBox=*/4 + /*XLBox=*/8 + /*TBox=*/4 + kFakePayload.length(), 'X');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(kLBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(kTBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint64_t>(kXlBox, cursor);
+  cursor += sizeof(uint64_t);
+  // Safe because size of string was computed to be large enough for
+  // kFakePayload above.
+  memcpy(cursor, kFakePayload.data(), kFakePayload.size());
+
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(
+      ConsumeBox(&input, /*allow_implicit_length=*/true),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               "not enough input bytes for declared box size; have 42 "
+               "input bytes, box declares its size as 8008888888 bytes"));
+  EXPECT_THAT(input, Eq(buffer));
+}
+
+TEST(ConsumeBoxTest, XlBoxSizeTooSmallFails) {
+  constexpr absl::string_view kFakePayload = "some random data blah blah";
+  constexpr uint32_t kLBox = 1;
+  constexpr uint32_t kTBox = 12345;
+  constexpr uint64_t kXlBox = 2;
+
+  std::string buffer(
+      /*LBox=*/4 + /*XLBox=*/8 + /*TBox=*/4 + kFakePayload.length(), 'X');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(kLBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(kTBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint64_t>(kXlBox, cursor);
+  cursor += sizeof(uint64_t);
+  // Safe because size of string was computed to be large enough for
+  // kFakePayload above.
+  memcpy(cursor, kFakePayload.data(), kFakePayload.size());
+
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "XLBox is 2, which is less than the number of bytes "
+                       "already consumed by this box's header"));
+  EXPECT_THAT(input, Eq(buffer));
+}
+
+TEST(ConsumeBoxTest, ReservedBoxLengthFails) {
+  constexpr uint32_t kLBox = 5;
+  constexpr uint32_t kTBox = 12345;
+
+  std::string buffer(8, 'A');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(kLBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(kTBox, cursor);
+  cursor += sizeof(uint32_t);
+
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(
+      ConsumeBox(&input, /*allow_implicit_length=*/true),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               "LBox is 5; LBox values 2-7 are reserved by the standard"));
+  EXPECT_THAT(input, Eq(buffer));
+}
+
+TEST(ConsumeBoxTest, ZeroBoxLengthSucceedsWhenPermitted) {
+  constexpr uint32_t kLBox = 0;
+  constexpr uint32_t kTBox = 12345;
+  constexpr absl::string_view kContent =
+      R"(blah blah blah blah when will I stop writing? I don't know so you won't know until you reach the end because the box length is 0 okay now I'm done bye)";
+
+  std::string buffer(8 + kContent.length(), '\0');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(kLBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(kTBox, cursor);
+  cursor += sizeof(uint32_t);
+  memcpy(cursor, kContent.data(), kContent.size());
+
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(ConsumeBox(&input, /*allow_implicit_length=*/true),
+              IsOkAndHolds(IntermediateBox{
+                  .payload = kContent,
+                  .serialized = buffer,
+                  .type = kTBox,
+                  .implicit_length = true,
+              }));
+  EXPECT_THAT(input, IsEmpty());
+}
+
+TEST(ConsumeBoxTest, ZeroBoxLengthFailsWhenNotPermitted) {
+  constexpr uint32_t kLBox = 0;
+  constexpr uint32_t kTBox = 12345;
+
+  std::string buffer(8, '\0');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(kLBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(kTBox, cursor);
+  cursor += sizeof(uint32_t);
+
+  absl::string_view input = buffer;
+
+  EXPECT_THAT(
+      ConsumeBox(&input, /*allow_implicit_length=*/false),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr("LBox is 0, indicating box extends to end of input, but "
+                    "context does not allow for implicit box lengths")));
+  EXPECT_THAT(input, Eq(buffer));
+}
+
+}  // namespace
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/encode.cc b/jumbf/internal/encode.cc
new file mode 100644
index 0000000..1b628bb
--- /dev/null
+++ b/jumbf/internal/encode.cc
@@ -0,0 +1,108 @@
+// 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 "jumbf/internal/encode.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <utility>
+
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/cord.h"
+#include "jumbf/constants.h"
+#include "jumbf/encode_params.h"
+#include "jumbf/internal/serialization_utils.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/backward_writer.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+using ::jumbf_internal::WriteInteger;
+using ::jumbf_internal::WriteNulTerminated;
+using ::jumbf_internal::WritePadding;
+using ::jumbf_internal::WriteRaw;
+
+static_assert(std::numeric_limits<uint64_t>::max() >=
+                  std::numeric_limits<size_t>::max(),
+              "This library converts from `size_t` to `uint64_t` when writing "
+              "XLBox values.");
+
+absl::Status EncodePaddingBox(uint64_t padding_size,
+                              riegeli::BackwardWriter* writer) {
+  ABSL_RETURN_IF_ERROR(WritePadding(padding_size, writer));
+  return EncodeBoxHeader(padding_size, kPaddingBoxType, writer);
+}
+
+absl::Status EncodeSerializedSuperBox(absl::Cord payload,
+                                      riegeli::BackwardWriter* writer) {
+  return WriteRaw(std::move(payload), writer);
+}
+
+absl::Status EncodeContentBox(uint32_t tbox, absl::Cord payload,
+                              riegeli::BackwardWriter* writer) {
+  size_t payload_size = payload.size();
+  ABSL_RETURN_IF_ERROR(WriteRaw(std::move(payload), writer));
+  return EncodeBoxHeader(payload_size, tbox, writer);
+}
+
+absl::Status EncodeDescriptionBox(credentio::Uuid type_uuid,
+                                  DescriptionBoxOptions options,
+                                  riegeli::BackwardWriter* writer) {
+  uint8_t toggles = 0;
+  const riegeli::Position pos_before = writer->pos();
+  if (options.private_content.has_value()) {
+    toggles |= kDescriptionTogglePrivatePresent;
+    ABSL_RETURN_IF_ERROR(WriteRaw(*std::move(options.private_content), writer));
+  }
+  if (options.label.has_value()) {
+    if (options.requestable_iff_label) {
+      toggles |= kDescriptionToggleRequestable;
+    }
+    toggles |= kDescriptionToggleLabelPresent;
+    ABSL_RETURN_IF_ERROR(WriteNulTerminated(*std::move(options.label), writer));
+  }
+  ABSL_RETURN_IF_ERROR(WriteInteger(toggles, writer));
+  ABSL_RETURN_IF_ERROR(WriteRaw(absl::Cord(type_uuid.ToProtoBytes()), writer));
+  return EncodeBoxHeader(writer->pos() - pos_before, kDescriptionBoxType,
+                         writer);
+}
+
+absl::Status EncodeBoxHeader(riegeli::Position payload_length, uint32_t tbox,
+                             riegeli::BackwardWriter* writer) {
+  // Minimum box length is payload + mandatory header fields.
+  constexpr size_t kMandatoryHeaderLength =
+      2 * sizeof(uint32_t);  // LBox and TBox
+  if (payload_length > std::numeric_limits<size_t>::max() -
+                           kMandatoryHeaderLength - sizeof(uint64_t)) {
+    return absl::OutOfRangeError("maximum box size exceeded");
+  }
+  size_t box_length = payload_length + kMandatoryHeaderLength;
+  uint32_t lbox;
+  if (box_length > std::numeric_limits<uint32_t>::max()) {
+    uint64_t xlbox = box_length + sizeof(uint64_t);
+    ABSL_RETURN_IF_ERROR(WriteInteger(xlbox, writer));
+    lbox = 1;
+  } else {
+    lbox = box_length;
+  }
+  ABSL_RETURN_IF_ERROR(WriteInteger(tbox, writer));
+  return WriteInteger(lbox, writer);
+}
+
+}  // namespace jumbf
diff --git a/jumbf/internal/encode.h b/jumbf/internal/encode.h
new file mode 100644
index 0000000..3990565
--- /dev/null
+++ b/jumbf/internal/encode.h
@@ -0,0 +1,72 @@
+// 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.
+//
+
+// Internal encoding primitives: Functions for writing individual JUMBF
+// constructs.
+//
+// These functions each write a single JUMBF construct. Care is needed to use
+// them properly, as callers must track the number of bytes written to properly
+// close out Superboxes.
+//
+// For example, to create a superbox containing a single CBOR content box:
+//
+// size_t superbox_size = 0;
+// CHECK_OK(EncodeContentBox(kCborBoxType, cbor_data, &writer, &superbox_size));
+// CHECK_OK(EncodeDescriptionBox(kCborBoxTypeUuid, DescriptionBoxOptions{
+//   .label = riegeli::ExternalRef::From("mylabel"),
+// }, &writer, &superbox_size));
+// CHECK_OK(EncodeBoxHeader(superbox_size, kSuperBoxType, &writer,
+//   &superbox_size));
+//
+// The public `SuperBoxBuilder` class in
+// c2pa/jumbf/box_builder.h is easier to use.
+
+#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/strings/cord.h"
+#include "jumbf/encode_params.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/backward_writer.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+// Writes a padding box to `writer`.
+absl::Status EncodePaddingBox(uint64_t padding_size,
+                              riegeli::BackwardWriter* writer);
+
+// Writes `payload` of the serialized super box to `writer`.
+absl::Status EncodeSerializedSuperBox(absl::Cord payload,
+                                      riegeli::BackwardWriter* writer);
+
+// Wraps `payload` in a box of type `tbox` and writes it to `writer`.
+absl::Status EncodeContentBox(uint32_t tbox, absl::Cord payload,
+                              riegeli::BackwardWriter* writer);
+
+// Writes a description box to `writer`.
+absl::Status EncodeDescriptionBox(credentio::Uuid type_uuid,
+                                  DescriptionBoxOptions options,
+                                  riegeli::BackwardWriter* writer);
+
+// Closes a box by writing the header to `writer`.
+absl::Status EncodeBoxHeader(riegeli::Position payload_length, uint32_t tbox,
+                             riegeli::BackwardWriter* writer);
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_
diff --git a/jumbf/internal/encode_test.cc b/jumbf/internal/encode_test.cc
new file mode 100644
index 0000000..b19b387
--- /dev/null
+++ b/jumbf/internal/encode_test.cc
@@ -0,0 +1,332 @@
+// 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 "jumbf/internal/encode.h"
+
+#include <sys/types.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/constants.h"
+#include "jumbf/encode_params.h"
+#include "jumbf/internal/serialization_utils.h"
+#include "jumbf/test_utils.h"
+#include "riegeli/base/chain.h"
+#include "riegeli/base/maker.h"
+#include "riegeli/bytes/chain_backward_writer.h"
+#include "riegeli/bytes/position_shifting_backward_writer.h"
+
+namespace jumbf {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::jumbf_internal::ConsumeInteger;
+using ::testing::Eq;
+using ::testing::IsEmpty;
+
+TEST(EncodePaddingBoxTest, EncodesPaddingBox) {
+  constexpr uint64_t kPaddingSize = 7;
+  constexpr size_t kExpectedOutputSize =
+      sizeof(uint32_t) /*LBox*/ + sizeof(uint32_t) /*TBox*/ + kPaddingSize;
+
+  riegeli::ChainBackwardWriter writer;
+  ABSL_ASSERT_OK(EncodePaddingBox(7, &writer));
+  writer.Close();
+  absl::string_view result_view = writer.dest().Flatten();
+
+  EXPECT_THAT(writer.pos(), kExpectedOutputSize);
+  // LBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kExpectedOutputSize));
+  // TBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds('free'));
+  EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, kPaddingSize),
+              IsOkAndHolds(std::string("\0\0\0\0\0\0\0\0", 7)));
+  EXPECT_THAT(result_view, IsEmpty());
+}
+
+TEST(EncodeContentBoxTest, EncodesContentBox) {
+  constexpr absl::string_view kPayload = R"(I am payload!)";
+  constexpr uint32_t kTbox = 0xfeedbabe;
+  constexpr size_t kExpectedOutputSize =
+      sizeof(uint32_t) /*LBox*/ + sizeof(uint32_t) /*TBox*/ + kPayload.length();
+  // Simulating content already written in superbox
+  constexpr size_t kTotalBytesInitial = 5280;
+
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kTotalBytesInitial));
+  ABSL_ASSERT_OK(EncodeContentBox(kTbox, CordFromConstexpr(kPayload), &writer));
+  writer.Close();
+  absl::string_view result_view = writer.dest().dest().Flatten();
+
+  EXPECT_THAT(writer.pos(), Eq(kTotalBytesInitial + kExpectedOutputSize));
+  // LBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kExpectedOutputSize));
+  // TBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kTbox));
+  EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, kPayload.length()),
+              IsOkAndHolds(kPayload));
+  EXPECT_THAT(result_view, IsEmpty());
+}
+
+TEST(EncodeDescriptionBoxTest, DefaultOptions) {
+  constexpr size_t kExpectedOutputSize = 25;
+  // Simulating content already written in superbox
+  constexpr size_t kTotalBytesInitial = 0xf00dbabe;
+
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kTotalBytesInitial));
+  ABSL_ASSERT_OK(
+      EncodeDescriptionBox(kCborBoxTypeUuid, DescriptionBoxOptions{}, &writer));
+
+  ASSERT_TRUE(writer.Close());
+  absl::string_view result_view = writer.dest().dest().Flatten();
+
+  EXPECT_THAT(writer.pos(), Eq(kExpectedOutputSize + 0xf00dbabe));
+
+  // LBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kExpectedOutputSize));
+  // TBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kDescriptionBoxType));
+
+  // Type UUID
+  EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, 16),
+              IsOkAndHolds(kCborBoxTypeUuid.ToProtoBytes()));
+  // Toggles
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint8_t>(&result_view),
+              IsOkAndHolds(0));
+
+  EXPECT_THAT(result_view, IsEmpty());
+}
+
+TEST(EncodeDescriptionBoxTest, HasLabel) {
+  constexpr absl::string_view kLabel = "mylabel";
+  constexpr size_t kExpectedOutputSize =
+      4 /*lbox*/ + 4 /*tbox*/ + 16 /*type UUID*/ + 1 /*toggles*/ +
+      kLabel.length() + 1 /*label NUL terminator*/;
+  // Simulating content already written in superbox
+  constexpr size_t kTotalBytesInitial = 0xdeadbeef;
+
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kTotalBytesInitial));
+  ABSL_ASSERT_OK(EncodeDescriptionBox(kCborBoxTypeUuid,
+                                      DescriptionBoxOptions{
+                                          .label = CordFromConstexpr(kLabel),
+                                      },
+                                      &writer));
+
+  ASSERT_TRUE(writer.Close());
+  absl::string_view result_view = writer.dest().dest().Flatten();
+
+  EXPECT_THAT(writer.pos(), Eq(kExpectedOutputSize + kTotalBytesInitial));
+
+  // LBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kExpectedOutputSize));
+  // TBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kDescriptionBoxType));
+
+  // Type UUID
+  EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, 16),
+              IsOkAndHolds(kCborBoxTypeUuid.ToProtoBytes()));
+  // Toggles
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint8_t>(&result_view),
+              IsOkAndHolds(kDescriptionToggleLabelPresent |
+                           kDescriptionToggleRequestable));
+  // Label
+  EXPECT_THAT(jumbf_internal::ConsumeNullTerminated(&result_view),
+              IsOkAndHolds("mylabel"));
+
+  EXPECT_THAT(result_view, IsEmpty());
+}
+
+TEST(EncodeDescriptionBoxTest, HasPrivateContent) {
+  constexpr absl::string_view kPrivateContent =
+      "Hi, I'm not actually JUMBF data but for this test it doesn't matter.";
+  constexpr size_t kExpectedOutputSize = 4 /*lbox*/ + 4 /*tbox*/ +
+                                         16 /*type UUID*/ + 1 /*toggles*/ +
+                                         kPrivateContent.length();
+  // Simulating content already written in superbox
+  constexpr size_t kTotalBytesInitial = 0xbaadf00d;
+
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kTotalBytesInitial));
+  ABSL_ASSERT_OK(EncodeDescriptionBox(
+      kCborBoxTypeUuid,
+      DescriptionBoxOptions{
+          .requestable_iff_label = false,
+          .private_content = CordFromConstexpr(kPrivateContent),
+      },
+      &writer));
+
+  ASSERT_TRUE(writer.Close());
+  absl::string_view result_view = writer.dest().dest().Flatten();
+
+  EXPECT_THAT(writer.pos(), Eq(kExpectedOutputSize + kTotalBytesInitial));
+
+  // LBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kExpectedOutputSize));
+  // TBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kDescriptionBoxType));
+
+  // Type UUID
+  EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, 16),
+              IsOkAndHolds(kCborBoxTypeUuid.ToProtoBytes()));
+  // Toggles
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint8_t>(&result_view),
+              IsOkAndHolds(kDescriptionTogglePrivatePresent));
+  // Private content
+  EXPECT_THAT(
+      jumbf_internal::ConsumeBytes(&result_view, kPrivateContent.length()),
+      IsOkAndHolds(kPrivateContent));
+
+  EXPECT_THAT(result_view, IsEmpty());
+}
+
+TEST(EncodeDescriptionBoxTest, HasLabelNotRequestable) {
+  constexpr size_t kExpectedOutputSize = 33;
+  constexpr absl::string_view kLabel = "mylabel";
+  // Simulating content already written in superbox
+  constexpr size_t kTotalBytesInitial = 0xdefec8ed;
+
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kTotalBytesInitial));
+  ABSL_ASSERT_OK(EncodeDescriptionBox(kCborBoxTypeUuid,
+                                      DescriptionBoxOptions{
+                                          .requestable_iff_label = false,
+                                          .label = CordFromConstexpr(kLabel),
+                                      },
+                                      &writer));
+
+  EXPECT_THAT(writer.pos(), Eq(kExpectedOutputSize + kTotalBytesInitial));
+
+  ASSERT_TRUE(writer.Close());
+  absl::string_view result_view = writer.dest().dest().Flatten();
+
+  // LBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kExpectedOutputSize));
+  // TBox
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(kDescriptionBoxType));
+
+  // Type UUID
+  EXPECT_THAT(jumbf_internal::ConsumeBytes(&result_view, 16),
+              IsOkAndHolds(kCborBoxTypeUuid.ToProtoBytes()));
+  // Toggles
+  EXPECT_THAT(jumbf_internal::ConsumeInteger<uint8_t>(&result_view),
+              IsOkAndHolds(kDescriptionToggleLabelPresent));
+  // Label
+  EXPECT_THAT(jumbf_internal::ConsumeNullTerminated(&result_view),
+              IsOkAndHolds("mylabel"));
+
+  EXPECT_THAT(result_view, IsEmpty());
+}
+
+TEST(EncodeBoxHeaderTest, LBox) {
+  // Fake value, pretending we already wrote some content to `writer`.
+  constexpr size_t kPayloadSize = 10;
+
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kPayloadSize));
+  ABSL_EXPECT_OK(EncodeBoxHeader(kPayloadSize, 0xdeadbeef, &writer));
+
+  ASSERT_TRUE(writer.Close());
+  absl::string_view result_view = writer.dest().dest().Flatten();
+
+  // Should have been updated to include the (fake) content plus LBox and TBox.
+  EXPECT_THAT(writer.pos(), Eq(kPayloadSize + 8));
+
+  // LBox
+  EXPECT_THAT(ConsumeInteger<uint32_t>(&result_view),
+              IsOkAndHolds(writer.pos()));
+  // TBox
+  EXPECT_THAT(ConsumeInteger<uint32_t>(&result_view), IsOkAndHolds(0xdeadbeef));
+  EXPECT_THAT(result_view, IsEmpty());
+}
+
+TEST(EncodeBoxHeaderTest, XLBox) {
+  // Fake value, pretending we already wrote some content to `writer`.
+  constexpr size_t kPayloadSize =
+      static_cast<size_t>(std::numeric_limits<uint32_t>::max()) + 10;
+
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kPayloadSize));
+  ABSL_EXPECT_OK(EncodeBoxHeader(kPayloadSize, 0xdeadbeef, &writer));
+
+  ASSERT_TRUE(writer.Close());
+  absl::string_view result_view = writer.dest().dest().Flatten();
+
+  // Should have been updated to include the (fake) content plus LBox, TBox, and
+  // XLBox.
+  EXPECT_THAT(writer.pos(), Eq(kPayloadSize + 16));
+
+  // LBox
+  EXPECT_THAT(ConsumeInteger<uint32_t>(&result_view), IsOkAndHolds(1));
+  // TBox
+  EXPECT_THAT(ConsumeInteger<uint32_t>(&result_view), IsOkAndHolds(0xdeadbeef));
+  // XLBox
+  EXPECT_THAT(ConsumeInteger<uint64_t>(&result_view),
+              IsOkAndHolds(writer.pos()));
+  EXPECT_THAT(result_view, IsEmpty());
+}
+
+TEST(EncodeBoxHeaderTest, XLBoxPayloadExceedsMaxBoxSize) {
+  // Fake value, pretending we already wrote some content to `writer`.
+  constexpr size_t kPayloadSize = std::numeric_limits<size_t>::max();
+
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kPayloadSize));
+  EXPECT_THAT(
+      EncodeBoxHeader(kPayloadSize, 0xdeadbeef, &writer),
+      StatusIs(absl::StatusCode::kOutOfRange, "maximum box size exceeded"));
+}
+
+}  // namespace
+}  // namespace jumbf
diff --git a/jumbf/internal/intermediate.h b/jumbf/internal/intermediate.h
new file mode 100644
index 0000000..81c3fd8
--- /dev/null
+++ b/jumbf/internal/intermediate.h
@@ -0,0 +1,79 @@
+// 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.
+//
+
+// Intermediate types to represent partially-parsed JUMBF structures. Most
+// contain references back to the underlying serialized array to minimize
+// copies. This behavior is more thoroughly documented at the functions that
+// return these structs.
+#ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_INTERMEDIATE_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_INTERMEDIATE_H_
+
+#include <cstdint>
+#include <optional>
+#include <vector>
+
+#include "absl/strings/string_view.h"
+#include "uuid/uuid.h"
+
+namespace jumbf_internal {
+
+// A generic box structure, may be of any type (see section 4.3 of standard)
+struct IntermediateBox {
+  absl::string_view payload;
+  // The original serialized representation of this box.
+  absl::string_view serialized;
+  uint32_t type;
+  // Whether this box has an LBox of 0, indicating that it extends to the end
+  // of the file.
+  bool implicit_length;
+
+  bool operator==(const IntermediateBox&) const = default;
+};
+
+// A description box (see section A.3 of standard)
+struct IntermediateDescriptionBox {
+  // The type UUID, referred to simply as "type" in section A.3, not to be
+  // confused with the 32 bit box type described in section 4.3 (all description
+  // boxes must have box type "jumd").
+  credentio::Uuid type_uuid;
+
+  bool requestable;
+  std::optional<absl::string_view> label;
+  std::optional<uint32_t> id;
+  // The SHA-256 hash of the associated content boxes. In identical byte order
+  // to the serialized form as defined in the standard. Note that this value is
+  // not verified during the initial parse stage that produces this intermediate
+  // struct.
+  std::optional<absl::string_view> hash;
+  std::optional<IntermediateBox> private_box;
+
+  bool operator==(const IntermediateDescriptionBox&) const = default;
+};
+
+// A Super Box, according to the naming convention used in this library. In the
+// standard it's referred to as a "JUMBF Box" with the term "superbox" simply
+// meaning a box that contains other boxes (see section A.2 of the standard).
+struct IntermediateSuperBox {
+  IntermediateDescriptionBox description;
+  std::vector<IntermediateBox> content;
+  // The original serialized representation of this box.
+  absl::string_view serialized;
+
+  bool operator==(const IntermediateSuperBox&) const = default;
+};
+
+}  // namespace jumbf_internal
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_INTERMEDIATE_H_
diff --git a/jumbf/internal/parse_description_box.cc b/jumbf/internal/parse_description_box.cc
new file mode 100644
index 0000000..b2d4d37
--- /dev/null
+++ b/jumbf/internal/parse_description_box.cc
@@ -0,0 +1,87 @@
+// 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 "jumbf/internal/parse_description_box.h"
+
+#include <cstdint>
+#include <utility>
+
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/constants.h"
+#include "jumbf/internal/consume_box.h"
+#include "jumbf/internal/intermediate.h"
+#include "jumbf/internal/serialization_utils.h"
+#include "uuid/uuid.h"
+
+namespace jumbf_internal {
+
+absl::StatusOr<IntermediateDescriptionBox> ParseDescriptionBox(
+    const IntermediateBox& unwrapped_box) {
+  if (unwrapped_box.type != jumbf::kDescriptionBoxType) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("box is not a description box; got type 0x",
+                     absl::Hex(unwrapped_box.type), " need 0x",
+                     absl::Hex(jumbf::kDescriptionBoxType), " ('jumd')"));
+  }
+  IntermediateDescriptionBox description_box;
+
+  absl::string_view content = unwrapped_box.payload;
+  ABSL_ASSIGN_OR_RETURN(auto uuid_str, ConsumeBytes(&content, 16));
+  ABSL_ASSIGN_OR_RETURN(auto type_uuid,
+                        credentio::Uuid::FromProtoBytes(std::move(uuid_str)));
+  description_box.type_uuid = std::move(type_uuid);
+
+  ABSL_ASSIGN_OR_RETURN(auto toggles, ConsumeInteger<uint8_t>(&content),
+                        _.SetPrepend() << "cannot read toggles: ");
+
+  description_box.requestable = toggles & jumbf::kDescriptionToggleRequestable;
+
+  if (toggles & jumbf::kDescriptionToggleLabelPresent) {
+    ABSL_ASSIGN_OR_RETURN(description_box.label,
+                          ConsumeNullTerminated(&content),
+                          _.SetPrepend() << "cannot read label: ");
+  } else {
+    if (description_box.requestable) {
+      return absl::InvalidArgumentError(
+          R"(requestable bit is set, but box does not have a label; if a box is requestable the label is required)");
+    }
+  }
+
+  if (toggles & jumbf::kDescriptionToggleIdPresent) {
+    ABSL_ASSIGN_OR_RETURN(description_box.id,
+                          ConsumeInteger<uint32_t>(&content),
+                          _.SetPrepend() << "cannot read id: ");
+  }
+
+  if (toggles & jumbf::kDescriptionToggleHashPresent) {
+    ABSL_ASSIGN_OR_RETURN(description_box.hash, ConsumeBytes(&content, 32),
+                          _.SetPrepend() << "cannot read hash: ");
+  }
+
+  if (toggles & jumbf::kDescriptionTogglePrivatePresent) {
+    ABSL_ASSIGN_OR_RETURN(description_box.private_box,
+                          ConsumeBox(&content, /*allow_implicit_length=*/false),
+                          _.SetPrepend() << "cannot read private content: ");
+  }
+
+  return description_box;
+}
+
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/parse_description_box.h b/jumbf/internal/parse_description_box.h
new file mode 100644
index 0000000..5fc3359
--- /dev/null
+++ b/jumbf/internal/parse_description_box.h
@@ -0,0 +1,29 @@
+// 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_JUMBF_INTERNAL_PARSE_DESCRIPTION_BOX_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_DESCRIPTION_BOX_H_
+
+#include "absl/status/statusor.h"
+#include "jumbf/internal/intermediate.h"
+
+namespace jumbf_internal {
+
+absl::StatusOr<IntermediateDescriptionBox> ParseDescriptionBox(
+    const IntermediateBox& unwrapped_box);
+
+}  // namespace jumbf_internal
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_DESCRIPTION_BOX_H_
diff --git a/jumbf/internal/parse_description_box_test.cc b/jumbf/internal/parse_description_box_test.cc
new file mode 100644
index 0000000..e239d5c
--- /dev/null
+++ b/jumbf/internal/parse_description_box_test.cc
@@ -0,0 +1,311 @@
+// 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 "jumbf/internal/parse_description_box.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/constants.h"
+#include "jumbf/internal/intermediate.h"
+#include "jumbf/test_utils.h"
+#include "riegeli/endian/endian_writing.h"
+#include "uuid/uuid.h"
+
+namespace jumbf_internal {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio::Uuid;
+using ::jumbf::SerializeUuid;
+
+constexpr absl::string_view kFakeUuid =
+    R"(deadbeef-f00d-babe-b4df-00d528013378)";
+
+TEST(ParseDescriptionBoxTest, ParseOkNoToggles) {
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1;
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(0, content_cursor);
+  content_cursor += sizeof(uint8_t);
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kDescriptionBoxType,
+  };
+
+  EXPECT_THAT(ParseDescriptionBox(unwrapped_description),
+              IsOkAndHolds(IntermediateDescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid)}));
+}
+
+TEST(ParseDescriptionBoxTest, InvalidPayloadSize) {
+  // A fuzzer crafted this box, with a payload that is too short.
+  IntermediateBox box = {
+      .payload = "",
+      .serialized = absl::string_view("\x00\x00\x00\x08jumd", 8),
+      .type = 1786080612,
+      .implicit_length = false};
+
+  EXPECT_THAT(ParseDescriptionBox(box),
+              StatusIs(absl::StatusCode::kOutOfRange));
+}
+
+TEST(ParseDescriptionBoxTest, ParseOkAllToggles) {
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  constexpr char kFakeLabel[] = "fakelabel";
+  constexpr uint32_t kFakeId = 0xB33FD00D;
+  const std::string fake_hash(32, 'X');
+  constexpr absl::string_view kPrivatePayload =
+      R"(The format of this data is unimportant for this test case.)";
+  const std::string private_box = jumbf::WrapBox(kPrivatePayload, 12345);
+  const size_t description_content_length =
+      /*uuid*/ 16 + /*toggles*/ 1 + strlen(kFakeLabel) +
+      /*label null terminator*/ 1 + /*id*/ 4 + fake_hash.length() +
+      private_box.length();
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  uint8_t toggles = jumbf::kDescriptionToggleRequestable |
+                    jumbf::kDescriptionToggleLabelPresent |
+                    jumbf::kDescriptionToggleIdPresent |
+                    jumbf::kDescriptionToggleHashPresent |
+                    jumbf::kDescriptionTogglePrivatePresent;
+  riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor);
+  content_cursor += sizeof(uint8_t);
+  memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1);
+  content_cursor += strlen(kFakeLabel) + 1;
+  riegeli::WriteBigEndian<uint32_t>(kFakeId, content_cursor);
+  content_cursor += sizeof(uint32_t);
+  memcpy(content_cursor, fake_hash.data(), 32);
+  content_cursor += 32;
+  memcpy(content_cursor, private_box.data(), private_box.length());
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kDescriptionBoxType,
+  };
+
+  EXPECT_THAT(ParseDescriptionBox(unwrapped_description),
+              IsOkAndHolds(IntermediateDescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                  .requestable = true,
+                  .label = kFakeLabel,
+                  .id = kFakeId,
+                  .hash = fake_hash,
+                  .private_box =
+                      IntermediateBox{
+                          .payload = kPrivatePayload,
+                          .serialized = private_box,
+                          .type = 12345,
+                      },
+              }));
+}
+
+TEST(ParseDescriptionBoxTest, RequestableNoLabelFails) {
+  const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1;
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleRequestable,
+                                   content_cursor);
+  content_cursor += sizeof(uint8_t);
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kDescriptionBoxType,
+  };
+
+  EXPECT_THAT(ParseDescriptionBox(unwrapped_description),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "requestable bit is set, but box does not have a label; "
+                       "if a box is requestable the label is required"));
+}
+
+TEST(ParseDescriptionBoxTest, ParseOkWithLabel) {
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  constexpr char kFakeLabel[] = "fakelabel";
+  const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 +
+                                            strlen(kFakeLabel) +
+                                            /*label null terminator*/ 1;
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleLabelPresent,
+                                   content_cursor);
+  content_cursor += sizeof(uint8_t);
+  memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1);
+  content_cursor += strlen(kFakeLabel) + 1;
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kDescriptionBoxType,
+  };
+
+  EXPECT_THAT(
+      ParseDescriptionBox(unwrapped_description),
+      IsOkAndHolds(IntermediateDescriptionBox{
+          .type_uuid = Uuid::FromStringOrDie(kFakeUuid), .label = kFakeLabel}));
+}
+
+TEST(ParseDescriptionBoxTest, ParseOkWithId) {
+  constexpr uint32_t kFakeId = 0xB33FD00D;
+  const size_t description_content_length =
+      /*uuid*/ 16 + /*toggles*/ 1 + /*id*/ 4;
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleIdPresent,
+                                   content_cursor);
+  content_cursor += sizeof(uint8_t);
+  riegeli::WriteBigEndian<uint32_t>(kFakeId, content_cursor);
+  content_cursor += sizeof(uint32_t);
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kDescriptionBoxType,
+  };
+
+  EXPECT_THAT(
+      ParseDescriptionBox(unwrapped_description),
+      IsOkAndHolds(IntermediateDescriptionBox{
+          .type_uuid = Uuid::FromStringOrDie(kFakeUuid), .id = kFakeId}));
+}
+
+TEST(ParseDescriptionBoxTest, ParseOkWithHash) {
+  const std::string fake_hash(32, 'X');
+  const size_t description_content_length =
+      /*uuid*/ 16 + /*toggles*/ 1 + fake_hash.length();
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleHashPresent,
+                                   content_cursor);
+  content_cursor += sizeof(uint8_t);
+  memcpy(content_cursor, fake_hash.data(), 32);
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kDescriptionBoxType,
+  };
+
+  EXPECT_THAT(
+      ParseDescriptionBox(unwrapped_description),
+      IsOkAndHolds(IntermediateDescriptionBox{
+          .type_uuid = Uuid::FromStringOrDie(kFakeUuid), .hash = fake_hash}));
+}
+
+TEST(ParseDescriptionBoxTest, ParseOkWithPrivate) {
+  constexpr absl::string_view kPrivatePayload =
+      R"(The format of this data is unimportant for this test case.)";
+  const std::string private_box = jumbf::WrapBox(kPrivatePayload, 12345);
+  const size_t description_content_length =
+      /*uuid*/ 16 + /*toggles*/ 1 + private_box.length();
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionTogglePrivatePresent,
+                                   content_cursor);
+  content_cursor += sizeof(uint8_t);
+  memcpy(content_cursor, private_box.data(), private_box.length());
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kDescriptionBoxType,
+  };
+
+  EXPECT_THAT(ParseDescriptionBox(unwrapped_description),
+              IsOkAndHolds(IntermediateDescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                  .private_box =
+                      IntermediateBox{
+                          .payload = kPrivatePayload,
+                          .serialized = private_box,
+                          .type = 12345,
+                      },
+              }));
+}
+
+TEST(ParseDescriptionBoxTest, WrongTypeFails) {
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1;
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(0, content_cursor);
+  content_cursor += sizeof(uint8_t);
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kSuperBoxType,
+  };
+
+  EXPECT_THAT(ParseDescriptionBox(unwrapped_description),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "box is not a description box; got type 0x6a756d62 need "
+                       "0x6a756d64 ('jumd')"));
+}
+
+TEST(ParseDescriptionBoxTest, UnterminatedLabelFails) {
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  constexpr char kFakeLabel[] = "fakelabel";
+  const size_t description_content_length =
+      /*uuid*/ 16 + /*toggles*/ 1 + strlen(kFakeLabel);
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleLabelPresent,
+                                   content_cursor);
+  content_cursor += sizeof(uint8_t);
+  // Intentionally not copying null terminator.
+  memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel));
+
+  IntermediateBox unwrapped_description{
+      .payload = fake_description_contents,
+      .type = jumbf::kDescriptionBoxType,
+  };
+
+  EXPECT_THAT(ParseDescriptionBox(unwrapped_description),
+              StatusIs(absl::StatusCode::kOutOfRange,
+                       "cannot read label: ran out of input bytes before "
+                       "reading null terminator"));
+}
+
+}  // namespace
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/parse_embedded_file_description_box.cc b/jumbf/internal/parse_embedded_file_description_box.cc
new file mode 100644
index 0000000..ae97422
--- /dev/null
+++ b/jumbf/internal/parse_embedded_file_description_box.cc
@@ -0,0 +1,68 @@
+// 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 "jumbf/internal/parse_embedded_file_description_box.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/box.h"
+#include "jumbf/constants.h"
+#include "jumbf/internal/intermediate.h"
+#include "jumbf/internal/serialization_utils.h"
+
+namespace jumbf_internal {
+
+absl::StatusOr<jumbf::EmbeddedFileDescriptionBox>
+ParseEmbeddedFileDescriptionBox(const IntermediateBox& unwrapped_box) {
+  if (unwrapped_box.type != jumbf::kEmbeddedFileDescriptionBoxType) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "box is not an embedded file description box; got type 0x",
+        absl::Hex(unwrapped_box.type), " need 0x",
+        absl::Hex(jumbf::kEmbeddedFileDescriptionBoxType), " ('bfdb')"));
+  }
+
+  absl::string_view temp_payload = unwrapped_box.payload;
+  jumbf::EmbeddedFileDescriptionBox efdb;
+
+  ABSL_ASSIGN_OR_RETURN(auto toggles, ConsumeInteger<uint8_t>(&temp_payload),
+                        _.SetPrepend() << "could not read toggles: ");
+  ABSL_ASSIGN_OR_RETURN(efdb.media_type, ConsumeNullTerminated(&temp_payload),
+                        _.SetPrepend() << "could not read media type: ");
+
+  if (toggles & jumbf::kEfdbToggleFileNamePresent) {
+    ABSL_ASSIGN_OR_RETURN(efdb.file_name, ConsumeNullTerminated(&temp_payload),
+                          _.SetPrepend() << "could not read file name: ");
+  }
+
+  if (toggles & jumbf::kEfdbToggleExternal) {
+    efdb.external = true;
+  }
+
+  if (!temp_payload.empty()) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("excess data in embedded file description box payload; "
+                     "after parsing complete, valid payload, ",
+                     temp_payload.length(), " bytes were remaining"));
+  }
+
+  return efdb;
+}
+
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/parse_embedded_file_description_box.h b/jumbf/internal/parse_embedded_file_description_box.h
new file mode 100644
index 0000000..ea096ef
--- /dev/null
+++ b/jumbf/internal/parse_embedded_file_description_box.h
@@ -0,0 +1,30 @@
+// 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_JUMBF_INTERNAL_PARSE_EMBEDDED_FILE_DESCRIPTION_BOX_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_EMBEDDED_FILE_DESCRIPTION_BOX_H_
+
+#include "absl/status/statusor.h"
+#include "jumbf/box.h"
+#include "jumbf/internal/intermediate.h"
+
+namespace jumbf_internal {
+
+absl::StatusOr<jumbf::EmbeddedFileDescriptionBox>
+ParseEmbeddedFileDescriptionBox(const IntermediateBox& unwrapped_box);
+
+}  // namespace jumbf_internal
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_EMBEDDED_FILE_DESCRIPTION_BOX_H_
diff --git a/jumbf/internal/parse_embedded_file_description_box_test.cc b/jumbf/internal/parse_embedded_file_description_box_test.cc
new file mode 100644
index 0000000..2a1bced
--- /dev/null
+++ b/jumbf/internal/parse_embedded_file_description_box_test.cc
@@ -0,0 +1,207 @@
+// 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 "jumbf/internal/parse_embedded_file_description_box.h"
+
+#include <cstdint>
+#include <cstring>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/constants.h"
+#include "jumbf/internal/intermediate.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace jumbf_internal {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+
+TEST(ParseEmbeddedFileDescriptionBoxTest, ParseOkAllToggles) {
+  constexpr uint8_t kToggles =
+      jumbf::kEfdbToggleFileNamePresent | jumbf::kEfdbToggleExternal;
+  // Can't use absl::string_view because the linter does not believe it is
+  // null terminated, even when the underlying string definitely is.
+  constexpr const char kFakeMediatype[] = "media/MyFormat";
+  constexpr const char kFakePath[] = "path/to/some/file";
+
+  const size_t payload_length =
+      1 /*toggles*/ + sizeof(kFakeMediatype) + sizeof(kFakePath);
+  std::string payload(payload_length, 'A');
+  char* cursor = payload.data();
+  riegeli::WriteBigEndian<uint8_t>(kToggles, cursor);
+  cursor += sizeof(uint8_t);
+  memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype));
+  cursor += sizeof(kFakeMediatype);
+  memcpy(cursor, kFakePath, sizeof(kFakePath));
+  cursor += sizeof(kFakePath);
+
+  const IntermediateBox unwrapped_box{
+      .payload = payload,
+      .type = jumbf::kEmbeddedFileDescriptionBoxType,
+  };
+
+  auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box);
+
+  EXPECT_THAT(efdb, IsOkAndHolds(jumbf::EmbeddedFileDescriptionBox{
+                        .media_type = kFakeMediatype,
+                        .external = true,
+                        .file_name = kFakePath,
+                    }));
+}
+
+TEST(ParseEmbeddedFileDescriptionBoxTest, ParseOkFileNamePresent) {
+  constexpr uint8_t kToggles = jumbf::kEfdbToggleFileNamePresent;
+  // Can't use absl::string_view because the linter does not believe it is
+  // null terminated, even when the underlying string definitely is.
+  constexpr const char kFakeMediatype[] = "media/MyFormat";
+  constexpr const char kFakePath[] = "path/to/some/file";
+
+  const size_t payload_length =
+      1 /*toggles*/ + sizeof(kFakeMediatype) + sizeof(kFakePath);
+  std::string payload(payload_length, 'A');
+  char* cursor = payload.data();
+  riegeli::WriteBigEndian<uint8_t>(kToggles, cursor);
+  cursor += sizeof(uint8_t);
+  memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype));
+  cursor += sizeof(kFakeMediatype);
+  memcpy(cursor, kFakePath, sizeof(kFakePath));
+  cursor += sizeof(kFakePath);
+
+  const IntermediateBox unwrapped_box{
+      .payload = payload,
+      .type = jumbf::kEmbeddedFileDescriptionBoxType,
+  };
+
+  auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box);
+
+  EXPECT_THAT(efdb, IsOkAndHolds(jumbf::EmbeddedFileDescriptionBox{
+                        .media_type = kFakeMediatype,
+                        .external = false,
+                        .file_name = kFakePath,
+                    }));
+}
+
+TEST(ParseEmbeddedFileDescriptionBoxTest, ParseOkExternal) {
+  constexpr uint8_t kToggles = jumbf::kEfdbToggleExternal;
+  // Can't use absl::string_view because the linter does not believe it is
+  // null terminated, even when the underlying string definitely is.
+  constexpr const char kFakeMediatype[] = "media/MyFormat";
+
+  const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype);
+  std::string payload(payload_length, 'A');
+  char* cursor = payload.data();
+  riegeli::WriteBigEndian<uint8_t>(kToggles, cursor);
+  cursor += sizeof(uint8_t);
+  memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype));
+  cursor += sizeof(kFakeMediatype);
+
+  const IntermediateBox unwrapped_box{
+      .payload = payload,
+      .type = jumbf::kEmbeddedFileDescriptionBoxType,
+  };
+
+  auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box);
+
+  EXPECT_THAT(efdb, IsOkAndHolds(jumbf::EmbeddedFileDescriptionBox{
+                        .media_type = kFakeMediatype,
+                        .external = true,
+                    }));
+}
+
+TEST(ParseEmbeddedFileDescriptionBoxTest, ParseOkNoTogglesl) {
+  constexpr uint8_t kToggles = 0;
+  constexpr const char kFakeMediatype[] = "media/MyFormat";
+
+  const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype);
+  std::string payload(payload_length, 'A');
+  char* cursor = payload.data();
+  riegeli::WriteBigEndian<uint8_t>(kToggles, cursor);
+  cursor += sizeof(uint8_t);
+  memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype));
+  cursor += sizeof(kFakeMediatype);
+
+  const IntermediateBox unwrapped_box{
+      .payload = payload,
+      .type = jumbf::kEmbeddedFileDescriptionBoxType,
+  };
+
+  auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box);
+
+  EXPECT_THAT(efdb, IsOkAndHolds(jumbf::EmbeddedFileDescriptionBox{
+                        .media_type = kFakeMediatype,
+                        .external = false,
+                    }));
+}
+
+TEST(ParseEmbeddedFileDescriptionBoxTest, WrongTypeFails) {
+  constexpr uint8_t kToggles = 0;
+  constexpr const char kFakeMediatype[] = "media/MyFormat";
+
+  const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype);
+  std::string payload(payload_length, 'A');
+  char* cursor = payload.data();
+  riegeli::WriteBigEndian<uint8_t>(kToggles, cursor);
+  cursor += sizeof(uint8_t);
+  memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype));
+  cursor += sizeof(kFakeMediatype);
+
+  const IntermediateBox unwrapped_box{
+      .payload = payload,
+      .type = jumbf::kSuperBoxType,
+  };
+
+  auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box);
+
+  EXPECT_THAT(efdb, StatusIs(absl::StatusCode::kInvalidArgument,
+                             "box is not an embedded file description box; got "
+                             "type 0x6a756d62 need 0x62666462 ('bfdb')"));
+}
+
+TEST(ParseEmbeddedFileDescriptionBoxTest, ExtraDataFails) {
+  constexpr uint8_t kToggles = 0;
+  constexpr const char kFakeMediatype[] = "media/MyFormat";
+
+  const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype);
+  std::string payload(payload_length, 'A');
+  char* cursor = payload.data();
+  riegeli::WriteBigEndian<uint8_t>(kToggles, cursor);
+  cursor += sizeof(uint8_t);
+  memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype));
+  cursor += sizeof(kFakeMediatype);
+
+  payload.append("some extra data nobody asked for");
+
+  const IntermediateBox unwrapped_box{
+      .payload = payload,
+      .type = jumbf::kEmbeddedFileDescriptionBoxType,
+  };
+
+  auto efdb = ParseEmbeddedFileDescriptionBox(unwrapped_box);
+
+  EXPECT_THAT(
+      efdb,
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               "excess data in embedded file description box payload; after "
+               "parsing complete, valid payload, 32 bytes were remaining"));
+}
+
+}  // namespace
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/parse_super_box.cc b/jumbf/internal/parse_super_box.cc
new file mode 100644
index 0000000..7b7cb30
--- /dev/null
+++ b/jumbf/internal/parse_super_box.cc
@@ -0,0 +1,88 @@
+// 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 "jumbf/internal/parse_super_box.h"
+
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/constants.h"
+#include "jumbf/internal/consume_box.h"
+#include "jumbf/internal/intermediate.h"
+#include "jumbf/internal/parse_description_box.h"
+
+namespace jumbf_internal {
+
+absl::StatusOr<IntermediateSuperBox> ParseSuperBox(
+    const IntermediateBox& unwrapped_super_box) {
+  if (unwrapped_super_box.type != jumbf::kSuperBoxType) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("box is not a super box: got type 0x",
+                     absl::Hex(unwrapped_super_box.type), " need 0x",
+                     absl::Hex(jumbf::kSuperBoxType), " ('jumb')"));
+  }
+
+  absl::string_view temp_super_box_payload = unwrapped_super_box.payload;
+
+  ABSL_ASSIGN_OR_RETURN(
+      auto unwrapped_description_box,
+      ConsumeBox(&temp_super_box_payload, /*allow_implicit_length=*/false),
+      _.SetPrepend() << "cannot unwrap description box: ");
+
+  ABSL_ASSIGN_OR_RETURN(auto description_box,
+                        ParseDescriptionBox(unwrapped_description_box),
+                        _.SetPrepend() << "cannot parse description box: ");
+
+  if (temp_super_box_payload.empty()) {
+    return absl::InvalidArgumentError(
+        R"(super box has no content; at least one content box is required)");
+  }
+
+  std::vector<IntermediateBox> content;
+  while (!temp_super_box_payload.empty()) {
+    ABSL_ASSIGN_OR_RETURN(
+        auto unwrapped_content,
+        ConsumeBox(
+            &temp_super_box_payload,
+            /*allow_implicit_length=*/unwrapped_super_box.implicit_length),
+        _.SetPrepend() << "cannot unwrap content box for index "
+                       << content.size() << ": ");
+    if (unwrapped_content.type == jumbf::kPaddingBoxType) {
+      if (!temp_super_box_payload.empty()) {
+        return absl::InvalidArgumentError(
+            absl::StrCat("input remaining after padding box; if a padding box "
+                         "is present within a super box, it must occupy the "
+                         "entire remainder of the box's size; after reading "
+                         "one padding box there were ",
+                         temp_super_box_payload.length(), " bytes remaining"));
+      }
+    } else {
+      content.push_back(std::move(unwrapped_content));
+    }
+  }
+
+  return IntermediateSuperBox{
+      .description = std::move(description_box),
+      .content = std::move(content),
+      .serialized = unwrapped_super_box.serialized,
+  };
+}
+
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/parse_super_box.h b/jumbf/internal/parse_super_box.h
new file mode 100644
index 0000000..e326e9b
--- /dev/null
+++ b/jumbf/internal/parse_super_box.h
@@ -0,0 +1,29 @@
+// 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_JUMBF_INTERNAL_PARSE_SUPER_BOX_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_SUPER_BOX_H_
+
+#include "absl/status/statusor.h"
+#include "jumbf/internal/intermediate.h"
+
+namespace jumbf_internal {
+
+absl::StatusOr<IntermediateSuperBox> ParseSuperBox(
+    const IntermediateBox& unwrapped_super_box);
+
+}  // namespace jumbf_internal
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_PARSE_SUPER_BOX_H_
diff --git a/jumbf/internal/parse_super_box_test.cc b/jumbf/internal/parse_super_box_test.cc
new file mode 100644
index 0000000..c7c0fad
--- /dev/null
+++ b/jumbf/internal/parse_super_box_test.cc
@@ -0,0 +1,298 @@
+// 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 "jumbf/internal/parse_super_box.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/constants.h"
+#include "jumbf/internal/intermediate.h"
+#include "jumbf/test_utils.h"
+#include "riegeli/endian/endian_writing.h"
+#include "uuid/uuid.h"
+
+namespace jumbf_internal {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio::Uuid;
+using ::jumbf::SerializeUuid;
+using ::jumbf::WrapBox;
+using ::testing::HasSubstr;
+
+constexpr absl::string_view kFakeUuid =
+    R"(deadbeef-f00d-babe-b4df-00d528013378)";
+
+std::string FakeDescriptionBoxContents() {
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  constexpr char kFakeLabel[] = "fakelabel";
+  const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 +
+                                            strlen(kFakeLabel) +
+                                            /*label null terminator*/ 1;
+  std::string fake_description_contents(description_content_length, '\0');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(jumbf::kDescriptionToggleLabelPresent,
+                                   content_cursor);
+  content_cursor += sizeof(uint8_t);
+  memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1);
+  content_cursor += strlen(kFakeLabel) + 1;
+
+  return fake_description_contents;
+}
+
+TEST(ParseSuperBoxTest, ParseOkSingleContent) {
+  std::string serialized_content_box =
+      WrapBox("fake CBOR data", jumbf::kCborBoxType);
+  std::string super_box_payload = absl::StrCat(
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType),
+      serialized_content_box);
+  std::string serialized_super_box =
+      WrapBox(super_box_payload, jumbf::kSuperBoxType);
+
+  IntermediateBox unwrapped_super_box{
+      .payload = super_box_payload,
+      .serialized = serialized_super_box,
+      .type = jumbf::kSuperBoxType,
+  };
+
+  EXPECT_THAT(
+      ParseSuperBox(unwrapped_super_box),
+      IsOkAndHolds(IntermediateSuperBox{
+          .description =
+              IntermediateDescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                  .label = "fakelabel",
+              },
+          .content = {IntermediateBox{.payload = "fake CBOR data",
+                                      .serialized = serialized_content_box,
+                                      .type = jumbf::kCborBoxType}},
+          .serialized = serialized_super_box}));
+}
+
+TEST(ParseSuperBoxTest, ParseOkTwoContent) {
+  std::string serialized_first_content_box =
+      WrapBox("first fake CBOR data", jumbf::kCborBoxType);
+  std::string serialized_second_content_box =
+      WrapBox("second fake CBOR data", jumbf::kCborBoxType);
+  std::string super_box_payload = absl::StrCat(
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType),
+      serialized_first_content_box, serialized_second_content_box);
+  std::string serialized_super_box =
+      WrapBox(super_box_payload, jumbf::kSuperBoxType);
+
+  IntermediateBox unwrapped_super_box{
+      .payload = super_box_payload,
+      .serialized = serialized_super_box,
+      .type = jumbf::kSuperBoxType,
+  };
+
+  EXPECT_THAT(
+      ParseSuperBox(unwrapped_super_box),
+      IsOkAndHolds(IntermediateSuperBox{
+          .description =
+              IntermediateDescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                  .label = "fakelabel",
+              },
+          .content =
+              {
+                  IntermediateBox{.payload = "first fake CBOR data",
+                                  .serialized = serialized_first_content_box,
+                                  .type = jumbf::kCborBoxType},
+                  IntermediateBox{.payload = "second fake CBOR data",
+                                  .serialized = serialized_second_content_box,
+                                  .type = jumbf::kCborBoxType},
+              },
+          .serialized = serialized_super_box}));
+}
+
+TEST(ParseSuperBoxTest, ParseOkPaddingBox) {
+  std::string serialized_content_box =
+      WrapBox("fake CBOR data", jumbf::kCborBoxType);
+  std::string serialized_padding_box =
+      WrapBox(std::string(199, '\0'), jumbf::kPaddingBoxType);
+  std::string super_box_payload = absl::StrCat(
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType),
+      serialized_content_box, serialized_padding_box);
+  std::string serialized_super_box =
+      WrapBox(super_box_payload, jumbf::kSuperBoxType);
+
+  IntermediateBox unwrapped_super_box{
+      .payload = super_box_payload,
+      .serialized = serialized_super_box,
+      .type = jumbf::kSuperBoxType,
+  };
+
+  EXPECT_THAT(ParseSuperBox(unwrapped_super_box),
+              IsOkAndHolds(IntermediateSuperBox{
+                  .description =
+                      IntermediateDescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .content =
+                      {
+                          IntermediateBox{.payload = "fake CBOR data",
+                                          .serialized = serialized_content_box,
+                                          .type = jumbf::kCborBoxType},
+                      },
+                  .serialized = serialized_super_box}));
+}
+
+TEST(ParseSuperBoxTest, ParseImplicitSuperBoxAndContent) {
+  std::string serialized_content_box =
+      jumbf::WrapUndeterminedLengthBox("fake CBOR data", jumbf::kCborBoxType);
+  std::string super_box_payload = absl::StrCat(
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType),
+      serialized_content_box);
+  std::string serialized_super_box =
+      jumbf::WrapUndeterminedLengthBox(super_box_payload, jumbf::kSuperBoxType);
+
+  IntermediateBox unwrapped_super_box{
+      .payload = super_box_payload,
+      .serialized = serialized_super_box,
+      .type = jumbf::kSuperBoxType,
+      .implicit_length = true,
+  };
+
+  EXPECT_THAT(ParseSuperBox(unwrapped_super_box),
+              IsOkAndHolds(IntermediateSuperBox{
+                  .description =
+                      IntermediateDescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .content =
+                      {
+                          IntermediateBox{
+                              .payload = "fake CBOR data",
+                              .serialized = serialized_content_box,
+                              .type = jumbf::kCborBoxType,
+                              .implicit_length = true,
+                          },
+                      },
+                  .serialized = serialized_super_box}));
+}
+
+TEST(ParseSuperBoxTest, ImplicitLengthContentInExplicitLengthSuperBoxFails) {
+  std::string serialized_content_box =
+      jumbf::WrapUndeterminedLengthBox("fake CBOR data", jumbf::kCborBoxType);
+  std::string inner_super_box_payload = absl::StrCat(
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType),
+      serialized_content_box);
+  std::string serialized_inner_super_box = jumbf::WrapUndeterminedLengthBox(
+      inner_super_box_payload, jumbf::kSuperBoxType);
+  std::string outer_super_box_payload = absl::StrCat(
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType),
+      serialized_inner_super_box);
+  std::string serialized_outer_super_box =
+      WrapBox(outer_super_box_payload, jumbf::kSuperBoxType);
+
+  IntermediateBox unwrapped_super_box{
+      .payload = outer_super_box_payload,
+      .serialized = serialized_outer_super_box,
+      .type = jumbf::kSuperBoxType,
+      .implicit_length = false,
+  };
+
+  EXPECT_THAT(
+      ParseSuperBox(unwrapped_super_box),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr("LBox is 0, indicating box extends to end of input, but "
+                    "context does not allow for implicit box lengths")));
+}
+
+TEST(ParseSuperBoxTest, NoContentFails) {
+  std::string super_box_payload =
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType);
+  std::string serialized_super_box =
+      WrapBox(super_box_payload, jumbf::kSuperBoxType);
+
+  IntermediateBox unwrapped_super_box{
+      .payload = super_box_payload,
+      .serialized = serialized_super_box,
+      .type = jumbf::kSuperBoxType,
+  };
+
+  EXPECT_THAT(
+      ParseSuperBox(unwrapped_super_box),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          "super box has no content; at least one content box is required"));
+}
+
+TEST(ParseSuperBoxTest, ContentAfterPaddingBoxFails) {
+  std::string serialized_content_box =
+      WrapBox("fake CBOR data", jumbf::kCborBoxType);
+  std::string serialized_padding_box =
+      WrapBox(std::string(199, '\0'), jumbf::kPaddingBoxType);
+  std::string super_box_payload = absl::StrCat(
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType),
+      serialized_content_box, serialized_padding_box,
+      WrapBox("extra content that should not be here", jumbf::kCborBoxType));
+  std::string serialized_super_box =
+      WrapBox(super_box_payload, jumbf::kSuperBoxType);
+
+  IntermediateBox unwrapped_super_box{
+      .payload = super_box_payload,
+      .serialized = serialized_super_box,
+      .type = jumbf::kSuperBoxType,
+  };
+
+  EXPECT_THAT(ParseSuperBox(unwrapped_super_box),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "input remaining after padding box; if a padding box is "
+                       "present within a super box, it must occupy the entire "
+                       "remainder of the box's size; after reading one padding "
+                       "box there were 45 bytes remaining"));
+}
+
+TEST(ParseSuperBoxTest, WrongTypeFails) {
+  std::string serialized_content_box =
+      WrapBox("fake CBOR data", jumbf::kCborBoxType);
+  std::string super_box_payload = absl::StrCat(
+      WrapBox(FakeDescriptionBoxContents(), jumbf::kDescriptionBoxType),
+      serialized_content_box);
+  std::string serialized_super_box =
+      WrapBox(super_box_payload, jumbf::kCborBoxType);
+
+  IntermediateBox unwrapped_super_box{
+      .payload = super_box_payload,
+      .serialized = serialized_super_box,
+      .type = jumbf::kCborBoxType,
+  };
+
+  EXPECT_THAT(ParseSuperBox(unwrapped_super_box),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "box is not a super box: got type 0x63626f72 need "
+                       "0x6a756d62 ('jumb')"));
+}
+
+}  // namespace
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/serialization_utils.cc b/jumbf/internal/serialization_utils.cc
new file mode 100644
index 0000000..01d2854
--- /dev/null
+++ b/jumbf/internal/serialization_utils.cc
@@ -0,0 +1,119 @@
+// 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 "jumbf/internal/serialization_utils.h"
+
+#include <climits>
+#include <cstddef>
+#include <cstdint>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/base/byte_fill.h"
+#include "riegeli/bytes/backward_writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+#define WRITER_CALL_OR_RETURN(writer, call) \
+  do {                                      \
+    if (!writer->call) {                    \
+      return writer->status();              \
+    }                                       \
+  } while (false)
+
+namespace jumbf_internal {
+
+// This library stores non-text data in strings and string_views, and reads
+// values from byte offsets within them. No effort has been made to take into
+// account alternate char widths.
+static_assert(CHAR_BIT == 8);
+
+absl::StatusOr<absl::string_view> ConsumeNullTerminated(absl::string_view* sv) {
+  size_t len = sv->find('\0');
+  if (len == absl::string_view::npos) {
+    return absl::OutOfRangeError(
+        "ran out of input bytes before reading null terminator");
+  }
+
+  // `len` does not include null terminator, which would have been contained
+  // within the bounds of `sv`.
+  absl::string_view output = sv->substr(0, len);
+  sv->remove_prefix(len + 1);
+  return output;
+}
+
+// Consumes the first `n` bytes of `sv`.
+absl::StatusOr<absl::string_view> ConsumeBytes(absl::string_view* sv,
+                                               size_t n) {
+  if (sv->length() < n) {
+    return absl::OutOfRangeError(absl::StrCat("ran out of input bytes; ", n,
+                                              " bytes requested, ",
+                                              sv->length(), " remaining"));
+  }
+
+  absl::string_view output = sv->substr(0, n);
+  sv->remove_prefix(n);
+  return output;
+}
+
+absl::Status WriteRaw(absl::Cord content, riegeli::BackwardWriter* writer) {
+  WRITER_CALL_OR_RETURN(writer, Write(std::move(content)));
+  return absl::OkStatus();
+}
+
+absl::Status WriteNulTerminated(absl::Cord content,
+                                riegeli::BackwardWriter* writer) {
+  for (absl::string_view chunk : content.Chunks()) {
+    if (absl::StrContains(chunk, '\0')) {
+      return absl::InvalidArgumentError(
+          "cannot create NUL-terminated string from content: content contains "
+          "NUL");
+    }
+  }
+  WRITER_CALL_OR_RETURN(writer, WriteByte('\0'));
+  WRITER_CALL_OR_RETURN(writer, Write(std::move(content)));
+  return absl::OkStatus();
+}
+
+template <>
+absl::Status WriteInteger(uint8_t n, riegeli::BackwardWriter* writer) {
+  WRITER_CALL_OR_RETURN(writer, WriteByte(n));
+  return absl::OkStatus();
+}
+template <>
+absl::Status WriteInteger(uint32_t n, riegeli::BackwardWriter* writer) {
+  char buf[4];
+  riegeli::WriteBigEndian<uint32_t>(n, buf);
+  WRITER_CALL_OR_RETURN(writer, Write(absl::string_view(buf, 4)));
+  return absl::OkStatus();
+}
+template <>
+absl::Status WriteInteger(uint64_t n, riegeli::BackwardWriter* writer) {
+  char buf[8];
+  riegeli::WriteBigEndian<uint64_t>(n, buf);
+  WRITER_CALL_OR_RETURN(writer, Write(absl::string_view(buf, 8)));
+  return absl::OkStatus();
+}
+
+absl::Status WritePadding(uint64_t length, riegeli::BackwardWriter* writer) {
+  WRITER_CALL_OR_RETURN(writer, Write(riegeli::ByteFill(length)));
+  return absl::OkStatus();
+}
+
+}  // namespace jumbf_internal
diff --git a/jumbf/internal/serialization_utils.h b/jumbf/internal/serialization_utils.h
new file mode 100644
index 0000000..ce72b5f
--- /dev/null
+++ b/jumbf/internal/serialization_utils.h
@@ -0,0 +1,74 @@
+// 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_JUMBF_INTERNAL_SERIALIZATION_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_SERIALIZATION_UTILS_H_
+
+#include <climits>
+#include <cstddef>
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/backward_writer.h"
+#include "riegeli/endian/endian_reading.h"
+
+static_assert(CHAR_BIT == 8);
+
+namespace jumbf_internal {
+
+// Consumes any integer type that is supported by riegeli::ReadBigEndian.
+template <typename T>
+absl::StatusOr<T> ConsumeInteger(absl::string_view* sv) {
+  if (sv->length() < sizeof(T)) {
+    return absl::OutOfRangeError(
+        absl::StrCat("not enough input bytes remaining; have ", sv->length(),
+                     " need ", sizeof(T)));
+  }
+
+  T value = riegeli::ReadBigEndian<T>(sv->data());
+  sv->remove_prefix(sizeof(T));
+
+  return value;
+}
+
+// Consumes a null terminated string from within `sv`. The string's null
+// terminator must be inside the usable range of `sv`.
+absl::StatusOr<absl::string_view> ConsumeNullTerminated(absl::string_view* sv);
+
+// Consumes the first `n` bytes of `sv`.
+absl::StatusOr<absl::string_view> ConsumeBytes(absl::string_view* sv, size_t n);
+
+// Writes `contents` to `writer`.
+absl::Status WriteRaw(absl::Cord content, riegeli::BackwardWriter* writer);
+
+// Writes `content` to `writer`, appending a NUL terminator.
+absl::Status WriteNulTerminated(absl::Cord content,
+                                riegeli::BackwardWriter* writer);
+
+// Writes `n` to `writer`. Only types with explicit template specializations
+// are supported.
+template <typename T>
+absl::Status WriteInteger(T n, riegeli::BackwardWriter* writer);
+
+// Writes `length` zero bytes to `writer`.
+absl::Status WritePadding(uint64_t length, riegeli::BackwardWriter* writer);
+
+}  // namespace jumbf_internal
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_SERIALIZATION_UTILS_H_
diff --git a/jumbf/internal/serialization_utils_test.cc b/jumbf/internal/serialization_utils_test.cc
new file mode 100644
index 0000000..4263156
--- /dev/null
+++ b/jumbf/internal/serialization_utils_test.cc
@@ -0,0 +1,251 @@
+// 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 "jumbf/internal/serialization_utils.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/test_utils.h"
+#include "riegeli/base/maker.h"
+#include "riegeli/bytes/chain_backward_writer.h"
+#include "riegeli/bytes/position_shifting_backward_writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace jumbf_internal {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::jumbf::CordFromConstexpr;
+using ::testing::Eq;
+using ::testing::IsEmpty;
+using ::testing::Test;
+
+constexpr absl::string_view kExtraData = "12345678";
+
+auto IsBoxSizeExceededStatus() {
+  return StatusIs(absl::StatusCode::kResourceExhausted);
+}
+
+TEST(ConsumeBigEndianTest, ConsumeSuccessfulExtraData) {
+  constexpr size_t kBufferLength = sizeof(uint32_t) + kExtraData.length();
+  std::string buffer(kBufferLength, 'A');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(0xF00DBABE, cursor);
+  cursor += sizeof(uint32_t);
+  memcpy(cursor, kExtraData.data(), kExtraData.length());
+
+  absl::string_view sv = buffer;
+  EXPECT_THAT(ConsumeInteger<uint32_t>(&sv), IsOkAndHolds(0xF00DBABE));
+  EXPECT_THAT(sv, Eq(kExtraData));
+}
+
+TEST(ConsumeBigEndianTest, ConsumeSuccessful) {
+  constexpr size_t kBufferLength = sizeof(uint32_t);
+  std::string buffer(kBufferLength, 'A');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(0xF00DBABE, cursor);
+  cursor += sizeof(uint32_t);
+
+  absl::string_view sv = buffer;
+  EXPECT_THAT(ConsumeInteger<uint32_t>(&sv), IsOkAndHolds(0xF00DBABE));
+  EXPECT_THAT(sv, IsEmpty());
+}
+
+TEST(ConsumeBigEndianTest, InputTooShort) {
+  constexpr size_t kBufferLength = sizeof(uint32_t);
+  std::string buffer(kBufferLength, 'A');
+  char* cursor = buffer.data();
+
+  riegeli::WriteBigEndian<uint32_t>(0xF00DBABE, cursor);
+  cursor += sizeof(uint32_t);
+
+  absl::string_view sv = buffer;
+  EXPECT_THAT(ConsumeInteger<uint64_t>(&sv),
+              StatusIs(absl::StatusCode::kOutOfRange,
+                       "not enough input bytes remaining; have 4 need 8"));
+  EXPECT_THAT(sv, Eq(buffer));
+}
+
+TEST(ConsumeBigEndianTest, InputEmpty) {
+  std::string buffer;
+
+  absl::string_view sv = buffer;
+  EXPECT_THAT(ConsumeInteger<uint64_t>(&sv),
+              StatusIs(absl::StatusCode::kOutOfRange,
+                       "not enough input bytes remaining; have 0 need 8"));
+  EXPECT_THAT(sv, Eq(buffer));
+}
+
+TEST(ConsumeNullTerminatedTest, ConsumeSuccessfulExtraData) {
+  // Can't directly initialize `absl::string_view` because the null in the
+  // middle confuses the constructor.
+  const char raw_input[] = "hello\0extradata";
+  absl::string_view sv(raw_input, sizeof(raw_input) - 1);
+
+  EXPECT_THAT(ConsumeNullTerminated(&sv), IsOkAndHolds("hello"));
+  EXPECT_THAT(sv, Eq("extradata"));
+}
+
+TEST(ConsumeNullTerminatedTest, ConsumeSuccessful) {
+  // Can't directly initialize `absl::string_view` because the null in the
+  // middle confuses the constructor.
+  const char raw_input[] = "hello\0";
+  absl::string_view sv(raw_input, sizeof(raw_input) - 1);
+
+  EXPECT_THAT(ConsumeNullTerminated(&sv), IsOkAndHolds("hello"));
+  EXPECT_THAT(sv, IsEmpty());
+}
+
+TEST(ConsumeNullTerminatedTest, NullTerminatorOutOfBounds) {
+  // A standard string, which is null terminated, but outside of the size of
+  // the `absl::string_view`.
+  absl::string_view sv = "hello";
+
+  EXPECT_THAT(
+      ConsumeNullTerminated(&sv),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               "ran out of input bytes before reading null terminator"));
+  EXPECT_THAT(sv, Eq("hello"));
+}
+
+TEST(ConsumeBytesTest, ConsumeSuccessful) {
+  constexpr absl::string_view kTestData = "87654321";
+  absl::string_view sv = kTestData;
+
+  EXPECT_THAT(ConsumeBytes(&sv, 8), IsOkAndHolds(kTestData));
+  EXPECT_THAT(sv, IsEmpty());
+}
+
+TEST(ConsumeBytesTest, ConsumeSuccessfulExtraData) {
+  constexpr absl::string_view kTestData = "87654321";
+  const std::string buffer = absl::StrCat(kTestData, kExtraData);
+  absl::string_view sv = buffer;
+
+  EXPECT_THAT(ConsumeBytes(&sv, 8), IsOkAndHolds(kTestData));
+  EXPECT_THAT(sv, Eq(kExtraData));
+}
+
+TEST(ConsumeBytesTest, LengthOutOfBounds) {
+  absl::string_view sv = kExtraData;
+
+  EXPECT_THAT(
+      ConsumeBytes(&sv, 11),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               "ran out of input bytes; 11 bytes requested, 8 remaining"));
+  EXPECT_THAT(sv, Eq(kExtraData));
+}
+
+TEST(WriteRawTest, WriteSucceeds) {
+  constexpr absl::string_view kPayload = R"(Hi, I am a payload!)";
+  riegeli::ChainBackwardWriter writer;
+
+  ABSL_ASSERT_OK(WriteRaw(CordFromConstexpr(kPayload), &writer));
+  ASSERT_TRUE(writer.Close());
+
+  EXPECT_THAT(writer.pos(), Eq(kPayload.length()));
+  EXPECT_THAT(writer.dest().Flatten(), Eq(kPayload));
+}
+
+TEST(WriteRawTest, MaximumSizeExceeded) {
+  constexpr absl::string_view kPayload = R"(Hi, I am a payload!)";
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          std::numeric_limits<size_t>::max()));
+
+  EXPECT_THAT(WriteRaw(CordFromConstexpr(kPayload), &writer),
+              IsBoxSizeExceededStatus());
+}
+
+TEST(WriteNulTerminatedTest, WriteSucceeds) {
+  constexpr absl::string_view kPayload = R"(Hi, I am a payload!)";
+  riegeli::ChainBackwardWriter writer;
+  // This is safe because the underlying string literal is actually NUL
+  // terminated.
+  absl::string_view expected(kPayload.data(), kPayload.length() + 1);
+
+  ABSL_ASSERT_OK(WriteNulTerminated(CordFromConstexpr(kPayload), &writer));
+  ASSERT_TRUE(writer.Close());
+
+  EXPECT_THAT(writer.pos(), Eq(kPayload.length() + 1));
+  EXPECT_THAT(writer.dest().Flatten(), Eq(expected));
+}
+
+TEST(WriteNulTerminatedTest, MaximumSizeExceeded) {
+  constexpr absl::string_view kPayload = R"(Hi, I am a payload!)";
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          std::numeric_limits<size_t>::max()));
+
+  EXPECT_THAT(WriteNulTerminated(CordFromConstexpr(kPayload), &writer),
+              IsBoxSizeExceededStatus());
+}
+
+template <typename T>
+class WriteIntegerTest : public Test {};
+
+TYPED_TEST_SUITE_P(WriteIntegerTest);
+
+TYPED_TEST_P(WriteIntegerTest, WriteSucceeds) {
+  const TypeParam expected = 42;
+  constexpr size_t kTotalBytesInitial = 55;
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          kTotalBytesInitial));
+
+  ABSL_ASSERT_OK(WriteInteger(expected, &writer));
+  ASSERT_TRUE(writer.Close());
+  absl::string_view result_view = writer.dest().dest().Flatten();
+
+  EXPECT_THAT(ConsumeInteger<TypeParam>(&result_view), IsOkAndHolds(expected));
+  EXPECT_THAT(result_view, IsEmpty());
+  EXPECT_THAT(writer.pos(), Eq(kTotalBytesInitial + sizeof(TypeParam)));
+}
+
+TYPED_TEST_P(WriteIntegerTest, MaximumSizeExceeded) {
+  const TypeParam expected = 42;
+  riegeli::PositionShiftingBackwardWriter writer(
+      riegeli::Maker<riegeli::ChainBackwardWriter>(),
+      riegeli::PositionShiftingBackwardWriterBase::Options().set_base_pos(
+          std::numeric_limits<size_t>::max()));
+
+  EXPECT_THAT(WriteInteger(expected, &writer), IsBoxSizeExceededStatus());
+}
+
+REGISTER_TYPED_TEST_SUITE_P(WriteIntegerTest, WriteSucceeds,
+                            MaximumSizeExceeded);
+
+using WriteIntegerTestTypes = testing::Types<uint8_t, uint32_t, uint64_t>;
+INSTANTIATE_TYPED_TEST_SUITE_P(SerializationUtilsTest, WriteIntegerTest,
+                               WriteIntegerTestTypes);
+
+}  // namespace
+}  // namespace jumbf_internal
diff --git a/jumbf/parse.cc b/jumbf/parse.cc
new file mode 100644
index 0000000..ce98f42
--- /dev/null
+++ b/jumbf/parse.cc
@@ -0,0 +1,210 @@
+// 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 "jumbf/parse.h"
+
+#include <algorithm>
+#include <optional>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/box.h"
+#include "jumbf/constants.h"
+#include "jumbf/internal/consume_box.h"
+#include "jumbf/internal/intermediate.h"
+#include "jumbf/internal/parse_embedded_file_description_box.h"
+#include "jumbf/internal/parse_super_box.h"
+
+namespace jumbf {
+
+using ::jumbf_internal::ConsumeBox;
+using ::jumbf_internal::IntermediateBox;
+using ::jumbf_internal::IntermediateDescriptionBox;
+using ::jumbf_internal::ParseEmbeddedFileDescriptionBox;
+
+namespace {
+
+absl::StatusOr<ContentBox> ParseContentBox(const IntermediateBox& unwrapped_box,
+                                           int recursion_limit);
+
+absl::StatusOr<SuperBox> ParseSuperBox(
+    const IntermediateBox& unwrapped_super_box, int recursion_limit);
+
+absl::StatusOr<DescriptionBox> FinalizeDescriptionBox(
+    const IntermediateDescriptionBox& intermediate_box, int recursion_limit) {
+  std::vector<ContentBox> private_contents;
+  if (intermediate_box.private_box.has_value()) {
+    const IntermediateBox& private_box = *intermediate_box.private_box;
+    absl::string_view private_content_view = private_box.payload;
+    if (private_box.type == kPrivateContentBoxType) {
+      // Special private box type, repeated list of arbitrary boxes.
+      while (!private_content_view.empty()) {
+        ABSL_ASSIGN_OR_RETURN(
+            auto unwrapped_private_element,
+            ConsumeBox(&private_content_view, /*allow_implicit_length=*/false),
+            _.SetPrepend() << "could not unwrap private content box for index "
+                           << private_contents.size() << ": ");
+
+        ABSL_ASSIGN_OR_RETURN(
+            auto private_element,
+            ParseContentBox(unwrapped_private_element, recursion_limit),
+            _.SetPrepend() << "could not parse private content box for index "
+                           << private_contents.size() << ": ");
+
+        private_contents.push_back(std::move(private_element));
+      }
+    } else {
+      ABSL_ASSIGN_OR_RETURN(auto private_element,
+                            ParseContentBox(private_box, recursion_limit));
+      private_contents.push_back(std::move(private_element));
+    }
+  }
+
+  return DescriptionBox{
+      .type_uuid = intermediate_box.type_uuid,
+      .requestable = intermediate_box.requestable,
+      .label = intermediate_box.label,
+      .id = intermediate_box.id,
+      .unvalidated_hash = intermediate_box.hash,
+      .private_content = std::move(private_contents),
+  };
+}
+
+absl::StatusOr<ContentBox> ParseContentBox(const IntermediateBox& unwrapped_box,
+                                           int recursion_limit) {
+  if (recursion_limit < 0) {
+    // All negative values mean "no recursion limit". Pick -1 to avoid worries
+    // about underflow when we subtract from it.
+    recursion_limit = -1;
+  }
+  switch (unwrapped_box.type) {
+    case kCborBoxType:
+      return ContentBox{
+          .raw_bytes = unwrapped_box.serialized,
+          .payload =
+              CborBox{
+                  .payload = unwrapped_box.payload,
+              },
+      };
+    case kSuperBoxType: {
+      if (recursion_limit == 0) {
+        return ContentBox{
+            .raw_bytes = unwrapped_box.serialized,
+            .payload =
+                SerializedSuperbox{
+                    .serialized = unwrapped_box.serialized,
+                },
+        };
+      } else {
+        ABSL_ASSIGN_OR_RETURN(
+            auto super_box,
+            ParseSuperBox(unwrapped_box, std::max(recursion_limit - 1, -1)));
+        return ContentBox{
+            .raw_bytes = unwrapped_box.serialized,
+            .payload = std::move(super_box),
+        };
+      }
+      break;
+    }
+    case kEmbeddedFileDescriptionBoxType: {
+      ABSL_ASSIGN_OR_RETURN(auto efdb,
+                            ParseEmbeddedFileDescriptionBox(unwrapped_box));
+      return ContentBox{
+          .raw_bytes = unwrapped_box.serialized,
+          .payload = std::move(efdb),
+      };
+    }
+    case kBinaryDataBoxType:
+      return ContentBox{
+          .raw_bytes = unwrapped_box.serialized,
+          .payload = BinaryDataBox{.payload = unwrapped_box.payload},
+      };
+    case kC2paSaltBoxType:
+      return ContentBox{
+          .raw_bytes = unwrapped_box.serialized,
+          .payload = C2paSaltBox{.salt = unwrapped_box.payload},
+      };
+    case kJsonBoxType:
+      return ContentBox{
+          .raw_bytes = unwrapped_box.serialized,
+          .payload = JsonBox{.payload = unwrapped_box.payload},
+      };
+    case kUuidBoxType:
+      return ContentBox{
+          .raw_bytes = unwrapped_box.serialized,
+          .payload = UuidBox{.payload = unwrapped_box.payload},
+      };
+    default:
+      return ContentBox{
+          .raw_bytes = unwrapped_box.serialized,
+          .payload =
+              UnknownBox{
+                  .type = unwrapped_box.type,
+                  .payload = unwrapped_box.payload,
+              },
+      };
+  }
+}
+
+absl::StatusOr<SuperBox> ParseSuperBox(
+    const IntermediateBox& unwrapped_super_box, int recursion_limit) {
+  ABSL_ASSIGN_OR_RETURN(auto intermediate_super_box,
+                        jumbf_internal::ParseSuperBox(unwrapped_super_box));
+
+  std::vector<ContentBox> contents;
+  contents.reserve(intermediate_super_box.content.size());
+  for (int i = 0; i < intermediate_super_box.content.size(); ++i) {
+    ABSL_ASSIGN_OR_RETURN(
+        auto content,
+        ParseContentBox(intermediate_super_box.content[i], recursion_limit),
+        _.SetPrepend() << "could not parse content box for index " << i
+                       << ": ");
+    contents.push_back(std::move(content));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(
+      auto description,
+      FinalizeDescriptionBox(intermediate_super_box.description,
+                             recursion_limit),
+      _.SetPrepend() << "could not finalize description box: ");
+
+  return SuperBox{
+      .description = std::move(description),
+      .contents = std::move(contents),
+      .raw_bytes = intermediate_super_box.serialized,
+  };
+}
+
+}  // namespace
+
+absl::StatusOr<SuperBox> ConsumeSuperBox(
+    absl::string_view* serialized_super_box, int recursion_limit,
+    bool allow_implicit_length) {
+  absl::string_view temp_serialized_super_box = *serialized_super_box;
+  ABSL_ASSIGN_OR_RETURN(
+      auto unwrapped_super_box,
+      ConsumeBox(&temp_serialized_super_box, allow_implicit_length),
+      _.SetPrepend() << "cannot unwrap super box: ");
+  ABSL_ASSIGN_OR_RETURN(auto result,
+                        ParseSuperBox(unwrapped_super_box, recursion_limit));
+  *serialized_super_box = temp_serialized_super_box;
+  return result;
+}
+
+}  // namespace jumbf
diff --git a/jumbf/parse.h b/jumbf/parse.h
new file mode 100644
index 0000000..3d83e98
--- /dev/null
+++ b/jumbf/parse.h
@@ -0,0 +1,45 @@
+// 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_JUMBF_PARSE_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_PARSE_H_
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/box.h"
+
+namespace jumbf {
+
+// Consumes the first serialized JUMBF super_box in `serialized_super_box`.
+// Performs only basic validation. Does not check that description boxes
+// correspond with content box types. `recursion_limit` is the maximum number of
+// levels of super_box that will be parsed. If negative, no limit will be
+// enforced. If the limit is reached, the raw content of any further nested
+// super_boxes will be returned as `SerializedSuperBox` structs. If parsing is
+// not successful, `serialized_super_box` will not be modified.
+//
+// `allow_implicit_length` determines if boxes may set LBox to 0 to indicate
+// that the box extends to the end of the input.
+//
+// The return value contains references (in the form of `absl::string_view`) to
+// the string underlying `serialized_super_box`. Callers must ensure that string
+// outlives the return value.
+absl::StatusOr<SuperBox> ConsumeSuperBox(
+    absl::string_view* serialized_super_box, int recursion_limit,
+    bool allow_implicit_length = true);
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_PARSE_H_
diff --git a/jumbf/parse_test.cc b/jumbf/parse_test.cc
new file mode 100644
index 0000000..5d1d76f
--- /dev/null
+++ b/jumbf/parse_test.cc
@@ -0,0 +1,722 @@
+// 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 "jumbf/parse.h"
+
+#include <cstdint>
+#include <cstring>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_join.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/constants.h"
+#include "jumbf/test_utils.h"
+#include "riegeli/endian/endian_writing.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+namespace {
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio::Uuid;
+using ::testing::Eq;
+using ::testing::IsEmpty;
+
+constexpr absl::string_view kFakeUuid = "DEADBEEF-F00D-BABE-B4DF-00D528013378";
+
+std::string FakeDescriptionBoxContents() {
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  constexpr char kFakeLabel[] = "fakelabel";
+  const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 +
+                                            strlen(kFakeLabel) +
+                                            /*label null terminator*/ 1;
+  std::string fake_description_contents(description_content_length, '\0');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  riegeli::WriteBigEndian<uint8_t>(kDescriptionToggleLabelPresent,
+                                   content_cursor);
+  content_cursor += sizeof(uint8_t);
+  memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1);
+  content_cursor += strlen(kFakeLabel) + 1;
+
+  return fake_description_contents;
+}
+
+TEST(ParseTest, InvalidSuperBoxFails) {
+  absl::string_view empty;
+
+  EXPECT_THAT(
+      ConsumeSuperBox(&empty, /*recursion_limit=*/1),
+      StatusIs(
+          absl::StatusCode::kOutOfRange,
+          R"(cannot unwrap super box: cannot read LBox value: not enough input bytes remaining; have 0 need 4)"));
+  EXPECT_THAT(empty, IsEmpty());
+}
+
+TEST(ParseTest, InvalidSuperBoxPayloadFails) {
+  absl::string_view kContent =
+      R"(This content doesn't really matter for this test case.)";
+  std::string serialized = WrapBox(kContent, 0xf00dbabe);
+  absl::string_view serialized_view = serialized;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "box is not a super box: got type 0xf00dbabe need "
+                       "0x6a756d62 ('jumb')"));
+  EXPECT_THAT(serialized_view, Eq(serialized));
+}
+
+TEST(ParseTest, InvalidDescriptionBoxFails) {
+  constexpr uint32_t kLBox = 5;
+  constexpr uint32_t kTBox = 12345;
+
+  std::string inner_box(8, '\0');
+  char* cursor = inner_box.data();
+
+  riegeli::WriteBigEndian<uint32_t>(kLBox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(kTBox, cursor);
+  cursor += sizeof(uint32_t);
+
+  const std::string super_box = WrapBox(inner_box, kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "cannot unwrap description box: LBox is 5; LBox values "
+                       "2-7 are reserved by the standard"));
+  EXPECT_THAT(serialized_view, Eq(super_box));
+}
+
+TEST(ParseTest, InvalidDescriptionBoxPayloadFails) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), /*tbox=*/12345);
+  std::string content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string super_box =
+      WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "cannot parse description box: box is not a description "
+                       "box; got type 0x3039 need 0x6a756d64 ('jumd')"));
+  EXPECT_THAT(serialized_view, Eq(super_box));
+}
+
+TEST(ParseTest, NegativeRecursionLimitCausesNoUnderflow) {
+  absl::string_view box_from_fuzzer(
+      "\000\000\000\000jumb\000\000\000\031jumd\000\000\000\000\000\000\000\000"
+      "\000\000\000\000\000\000\000\000\000\000\000\000\000jumb\000\000\000\031"
+      "jumd\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+      "\000\000\031\000\000\000\000\000\000\000\000\000",
+      78);
+  EXPECT_THAT(
+      ConsumeSuperBox(&box_from_fuzzer, /*recursion_limit=*/-2147483648),
+      StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseToProtoTest, AllDescriptionBoxTogglesSucceeds) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  constexpr absl::string_view kFakeSalt = R"(0123456701234567)";
+  const std::string private_box = jumbf::WrapBox(kFakeSalt, kC2paSaltBoxType);
+
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  constexpr char kFakeLabel[] = "fakelabel";
+  constexpr uint32_t kFakeId = 0xB33FD00D;
+  const std::string fake_hash(32, 'X');
+  const size_t description_content_length =
+      /*uuid*/ 16 + /*toggles*/ 1 + strlen(kFakeLabel) +
+      /*label null terminator*/ 1 + /*id*/ 4 + fake_hash.length() +
+      private_box.length();
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  uint8_t toggles = jumbf::kDescriptionToggleRequestable |
+                    jumbf::kDescriptionToggleLabelPresent |
+                    jumbf::kDescriptionToggleIdPresent |
+                    jumbf::kDescriptionToggleHashPresent |
+                    jumbf::kDescriptionTogglePrivatePresent;
+  riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor);
+  content_cursor += sizeof(uint8_t);
+  memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1);
+  content_cursor += strlen(kFakeLabel) + 1;
+  riegeli::WriteBigEndian<uint32_t>(kFakeId, content_cursor);
+  content_cursor += sizeof(uint32_t);
+  memcpy(content_cursor, fake_hash.data(), 32);
+  content_cursor += 32;
+  memcpy(content_cursor, private_box.data(), private_box.length());
+
+  std::string content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string super_box = WrapBox(
+      absl::StrCat(WrapBox(fake_description_contents, kDescriptionBoxType),
+                   content_box),
+      kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              IsOkAndHolds(SuperBox{
+                  .description =
+                      DescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .requestable = true,
+                          .label = "fakelabel",
+                          .id = kFakeId,
+                          .unvalidated_hash = fake_hash,
+                          .private_content{ContentBox{.raw_bytes = private_box,
+                                                      .payload =
+                                                          C2paSaltBox{
+                                                              .salt = kFakeSalt,
+                                                          }}}},
+                  .contents = {ContentBox{.raw_bytes = content_box,
+                                          .payload =
+                                              CborBox{
+                                                  .payload = kFakeCborData,
+                                              }}},
+                  .raw_bytes = super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseToProtoTest, DescriptionBoxMultiplePrivateContentSucceeds) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  constexpr absl::string_view kFirstPrivatePayload =
+      R"(The format of this data is unimportant for this test case.)";
+  constexpr absl::string_view kSecondPrivatePayload =
+      R"(The format of this data is also unimportant for this test case.)";
+  const std::string private_box_payload[] = {WrapBox(kFirstPrivatePayload, 1),
+                                             WrapBox(kSecondPrivatePayload, 2)};
+  const std::string private_box =
+      WrapBox(absl::StrJoin(private_box_payload, ""), kPrivateContentBoxType);
+
+  // Can't use absl::string_view because the compiler warns against assuming it
+  // is null terminated, even when it truly is null terminated.
+  constexpr char kFakeLabel[] = "fakelabel";
+  constexpr uint32_t kFakeId = 0xB33FD00D;
+  const std::string fake_hash(32, 'X');
+  const size_t description_content_length =
+      /*uuid*/ 16 + /*toggles*/ 1 + strlen(kFakeLabel) +
+      /*label null terminator*/ 1 + /*id*/ 4 + fake_hash.length() +
+      private_box.length();
+  std::string fake_description_contents(description_content_length, 'A');
+  char* content_cursor = fake_description_contents.data();
+
+  SerializeUuid(&content_cursor, Uuid::FromStringOrDie(kFakeUuid));
+  uint8_t toggles =
+      kDescriptionToggleRequestable | kDescriptionToggleLabelPresent |
+      kDescriptionToggleIdPresent | kDescriptionToggleHashPresent |
+      kDescriptionTogglePrivatePresent;
+  riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor);
+  content_cursor += sizeof(uint8_t);
+  memcpy(content_cursor, kFakeLabel, strlen(kFakeLabel) + 1);
+  content_cursor += strlen(kFakeLabel) + 1;
+  riegeli::WriteBigEndian<uint32_t>(kFakeId, content_cursor);
+  content_cursor += sizeof(uint32_t);
+  memcpy(content_cursor, fake_hash.data(), 32);
+  content_cursor += 32;
+  memcpy(content_cursor, private_box.data(), private_box.length());
+
+  std::string content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string super_box = WrapBox(
+      absl::StrCat(WrapBox(fake_description_contents, kDescriptionBoxType),
+                   content_box),
+      kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(
+      ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+      IsOkAndHolds(SuperBox{
+          .description =
+              DescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                  .requestable = true,
+                  .label = "fakelabel",
+                  .id = kFakeId,
+                  .unvalidated_hash = fake_hash,
+                  .private_content{
+                      ContentBox{
+                          .raw_bytes = WrapBox(kFirstPrivatePayload, 1),
+                          .payload =
+                              UnknownBox{
+                                  .type = 1,
+                                  .payload = kFirstPrivatePayload,
+                              },
+                      },
+                      ContentBox{
+                          .raw_bytes = WrapBox(kSecondPrivatePayload, 2),
+                          .payload =
+                              UnknownBox{
+                                  .type = 2,
+                                  .payload = kSecondPrivatePayload,
+                              },
+                      },
+                  }},
+          .contents = {ContentBox{.raw_bytes = content_box,
+                                  .payload =
+                                      CborBox{
+                                          .payload = kFakeCborData,
+                                      }}},
+          .raw_bytes = super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, SingleCborContentBoxSucceeds) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string super_box =
+      WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              IsOkAndHolds(SuperBox{
+                  .description =
+                      DescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .contents = {ContentBox{.raw_bytes = content_box,
+                                          .payload =
+                                              CborBox{
+                                                  .payload = kFakeCborData,
+                                              }}},
+                  .raw_bytes = super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, MultipleCborContentBoxSucceeds) {
+  constexpr absl::string_view kFakeFirstCborData =
+      R"(first content: the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+  constexpr absl::string_view kFakeSecondCborData =
+      R"(second content: the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string super_box = WrapBox(
+      absl::StrCat(description_box, WrapBox(kFakeFirstCborData, kCborBoxType),
+                   WrapBox(kFakeSecondCborData, kCborBoxType)),
+      kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              IsOkAndHolds(SuperBox{
+                  .description =
+                      DescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .contents =
+                      {
+                          ContentBox{.raw_bytes = WrapBox(kFakeFirstCborData,
+                                                          kCborBoxType),
+                                     .payload =
+                                         CborBox{
+                                             .payload = kFakeFirstCborData,
+                                         }},
+                          ContentBox{.raw_bytes = WrapBox(kFakeSecondCborData,
+                                                          kCborBoxType),
+                                     .payload =
+                                         CborBox{
+                                             .payload = kFakeSecondCborData,
+                                         }},
+                      },
+                  .raw_bytes = super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, SingleEmbeddedFileDescriptionBoxSucceeds) {
+  constexpr uint8_t kToggles = 0;
+  constexpr const char kFakeMediatype[] = "media/MyFormat";
+
+  const size_t payload_length = 1 /*toggles*/ + sizeof(kFakeMediatype);
+  std::string payload(payload_length, 'A');
+  char* cursor = payload.data();
+  riegeli::WriteBigEndian<uint8_t>(kToggles, cursor);
+  cursor += sizeof(uint8_t);
+  memcpy(cursor, kFakeMediatype, sizeof(kFakeMediatype));
+  cursor += sizeof(kFakeMediatype);
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string content_box = WrapBox(payload, kEmbeddedFileDescriptionBoxType);
+  std::string super_box =
+      WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              IsOkAndHolds(SuperBox{
+                  .description =
+                      DescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .contents = {ContentBox{.raw_bytes = content_box,
+                                          .payload =
+                                              EmbeddedFileDescriptionBox{
+                                                  .media_type = kFakeMediatype,
+                                              }}},
+                  .raw_bytes = super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, SingleBinaryDataBoxSucceeds) {
+  constexpr absl::string_view kPayload =
+      R"(blah blah blah blah this is just some data in no particular format)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string content_box = WrapBox(kPayload, kBinaryDataBoxType);
+  std::string super_box =
+      WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              IsOkAndHolds(SuperBox{
+                  .description =
+                      DescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .contents = {ContentBox{.raw_bytes = content_box,
+                                          .payload =
+                                              BinaryDataBox{
+                                                  .payload = kPayload,
+                                              }}},
+                  .raw_bytes = super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, RecursiveSuperBoxUnderRecursionLimitSucceeds) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string inner_content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string inner_super_box =
+      WrapBox(absl::StrCat(description_box, inner_content_box), kSuperBoxType);
+
+  std::string outer_super_box =
+      WrapBox(absl::StrCat(description_box, inner_super_box), kSuperBoxType);
+
+  absl::string_view serialized_view = outer_super_box;
+
+  EXPECT_THAT(
+      ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+      IsOkAndHolds(SuperBox{
+          .description =
+              DescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                  .label = "fakelabel",
+              },
+          .contents = {ContentBox{
+              .raw_bytes = inner_super_box,
+              .payload =
+                  SuperBox{
+                      .description =
+                          DescriptionBox{
+                              .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                              .label = "fakelabel",
+                          },
+                      .contents = {ContentBox{.raw_bytes = inner_content_box,
+                                              .payload =
+                                                  CborBox{
+                                                      .payload = kFakeCborData,
+                                                  }}},
+                      .raw_bytes = inner_super_box}}},
+          .raw_bytes = outer_super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, RecursiveSuperBoxNoRecursionLimitSucceeds) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string inner_content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string inner_super_box =
+      WrapBox(absl::StrCat(description_box, inner_content_box), kSuperBoxType);
+
+  std::string outer_super_box =
+      WrapBox(absl::StrCat(description_box, inner_super_box), kSuperBoxType);
+
+  absl::string_view serialized_view = outer_super_box;
+
+  EXPECT_THAT(
+      ConsumeSuperBox(&serialized_view, /*recursion_limit=*/-1),
+      IsOkAndHolds(SuperBox{
+          .description =
+              DescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                  .label = "fakelabel",
+              },
+          .contents = {ContentBox{
+              .raw_bytes = inner_super_box,
+              .payload =
+                  SuperBox{
+                      .description =
+                          DescriptionBox{
+                              .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                              .label = "fakelabel",
+                          },
+                      .contents = {ContentBox{.raw_bytes = inner_content_box,
+                                              .payload =
+                                                  CborBox{
+                                                      .payload = kFakeCborData,
+                                                  }}},
+                      .raw_bytes = inner_super_box}}},
+          .raw_bytes = outer_super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, RecursiveSuperBoxExceedsZeroRecursionLimitSucceeds) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string inner_content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string inner_super_box =
+      WrapBox(absl::StrCat(description_box, inner_content_box), kSuperBoxType);
+
+  std::string outer_super_box =
+      WrapBox(absl::StrCat(description_box, inner_super_box), kSuperBoxType);
+
+  absl::string_view serialized_view = outer_super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/0),
+              IsOkAndHolds(SuperBox{
+                  .description =
+                      DescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .contents = {ContentBox{.raw_bytes = inner_super_box,
+                                          .payload =
+                                              SerializedSuperbox{
+                                                  .serialized = inner_super_box,
+                                              }}},
+                  .raw_bytes = outer_super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, RecursiveSuperBoxExceedsNonzeroRecursionLimitSucceeds) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string inner_content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string inner_super_box =
+      WrapBox(absl::StrCat(description_box, inner_content_box), kSuperBoxType);
+
+  std::string middle_super_box =
+      WrapBox(absl::StrCat(description_box, inner_super_box), kSuperBoxType);
+
+  std::string outer_super_box =
+      WrapBox(absl::StrCat(description_box, middle_super_box), kSuperBoxType);
+
+  absl::string_view serialized_view = outer_super_box;
+
+  EXPECT_THAT(
+      ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+      IsOkAndHolds(SuperBox{
+          .description =
+              DescriptionBox{
+                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                  .label = "fakelabel",
+              },
+          .contents = {ContentBox{
+              .raw_bytes = middle_super_box,
+              .payload = SuperBox{.description =
+                                      DescriptionBox{
+                                          .type_uuid =
+                                              Uuid::FromStringOrDie(kFakeUuid),
+                                          .label = "fakelabel",
+                                      },
+                                  .contents = {ContentBox{
+                                      .raw_bytes = inner_super_box,
+                                      .payload =
+                                          SerializedSuperbox{
+                                              .serialized = inner_super_box,
+                                          }}},
+                                  .raw_bytes = middle_super_box}}},
+          .raw_bytes = outer_super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, SingleUnknownContentBoxSucceeds) {
+  constexpr absl::string_view kUnknownContent =
+      R"(this is some data in a format this parser does not understand)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string content_box = WrapBox(kUnknownContent, 12345678);
+  std::string super_box =
+      WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              IsOkAndHolds(SuperBox{
+                  .description =
+                      DescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .contents = {ContentBox{.raw_bytes = content_box,
+                                          .payload =
+                                              UnknownBox{
+                                                  .type = 12345678,
+                                                  .payload = kUnknownContent,
+                                              }}},
+                  .raw_bytes = super_box}));
+
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, ExcessContentSucceeds) {
+  constexpr absl::string_view kFakeCborData =
+      R"(the function under test does not actually parse CBOR data so we'll just use a totally fake string here)";
+
+  constexpr absl::string_view kExtraData =
+      R"(this is some extra data in the input, the parser should not look at this)";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string content_box = WrapBox(kFakeCborData, kCborBoxType);
+  std::string super_box = absl::StrCat(
+      WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType),
+      kExtraData);
+
+  absl::string_view serialized_view = super_box;
+
+  EXPECT_THAT(ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1),
+              IsOkAndHolds(SuperBox{
+                  .description =
+                      DescriptionBox{
+                          .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                          .label = "fakelabel",
+                      },
+                  .contents = {ContentBox{.raw_bytes = content_box,
+                                          .payload =
+                                              CborBox{
+                                                  .payload = kFakeCborData,
+                                              }}},
+                  .raw_bytes = super_box.substr(
+                      0, super_box.size() - kExtraData.size())}));
+
+  EXPECT_THAT(serialized_view, Eq(kExtraData));
+}
+
+TEST(ParseTest, SingleJsonDataBoxSucceeds) {
+  constexpr absl::string_view kPayload = R"({"abc": "xyz"})";
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string content_box = WrapBox(kPayload, kJsonBoxType);
+  std::string super_box =
+      WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  auto superbox_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1);
+  ASSERT_TRUE(superbox_or.ok());
+  auto superbox = std::move(*superbox_or);
+  EXPECT_THAT(superbox,
+              Eq(SuperBox{.description =
+                              DescriptionBox{
+                                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                                  .label = "fakelabel",
+                              },
+                          .contents = {ContentBox{.raw_bytes = content_box,
+                                                  .payload =
+                                                      JsonBox{
+                                                          .payload = kPayload,
+                                                      }}},
+                          .raw_bytes = super_box}));
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+TEST(ParseTest, UuidBoxSucceeds) {
+  std::string uuid_box_payload = jumbf::kC2PARedactionUuid.ToProtoBytes();
+  absl::string_view payload = uuid_box_payload;
+
+  std::string description_box =
+      WrapBox(FakeDescriptionBoxContents(), kDescriptionBoxType);
+  std::string content_box = WrapBox(payload, kUuidBoxType);
+  std::string super_box =
+      WrapBox(absl::StrCat(description_box, content_box), kSuperBoxType);
+
+  absl::string_view serialized_view = super_box;
+
+  auto superbox_or = ConsumeSuperBox(&serialized_view, /*recursion_limit=*/1);
+  ASSERT_TRUE(superbox_or.ok());
+  auto superbox = std::move(*superbox_or);
+  EXPECT_THAT(superbox,
+              Eq(SuperBox{.description =
+                              DescriptionBox{
+                                  .type_uuid = Uuid::FromStringOrDie(kFakeUuid),
+                                  .label = "fakelabel",
+                              },
+                          .contents = {ContentBox{.raw_bytes = content_box,
+                                                  .payload =
+                                                      UuidBox{
+                                                          .payload = payload,
+                                                      }}},
+                          .raw_bytes = super_box}));
+  EXPECT_THAT(serialized_view, IsEmpty());
+}
+
+}  // namespace
+}  // namespace jumbf
diff --git a/jumbf/test_utils.cc b/jumbf/test_utils.cc
new file mode 100644
index 0000000..ae9eeb4
--- /dev/null
+++ b/jumbf/test_utils.cc
@@ -0,0 +1,130 @@
+// 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 "jumbf/test_utils.h"
+
+#include <cstdint>
+#include <cstring>
+#include <optional>
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_join.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "jumbf/constants.h"
+#include "riegeli/endian/endian_writing.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+std::string WrapBox(absl::string_view content, uint32_t tbox) {
+  const uint32_t lbox = content.length() + /*lbox*/ 4 + /*tbox*/ 4;
+  std::string serialized(lbox, '\0');
+  char* cursor = serialized.data();
+  riegeli::WriteBigEndian<uint32_t>(lbox, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(tbox, cursor);
+  cursor += sizeof(uint32_t);
+  // This is safe because the length of `serialized` has been calculated above
+  // to always have enough room for `content`.
+  memcpy(cursor, content.data(), content.length());
+  return serialized;
+}
+
+std::string WrapUndeterminedLengthBox(absl::string_view content,
+                                      uint32_t tbox) {
+  const uint32_t box_length = content.length() + /*lbox*/ 4 + /*tbox*/ 4;
+  std::string serialized(box_length, '\0');
+  char* cursor = serialized.data();
+  // LBox value
+  riegeli::WriteBigEndian<uint32_t>(0, cursor);
+  cursor += sizeof(uint32_t);
+  riegeli::WriteBigEndian<uint32_t>(tbox, cursor);
+  cursor += sizeof(uint32_t);
+  // This is safe because the length of `serialized` has been calculated above
+  // to always have enough room for `content`.
+  memcpy(cursor, content.data(), content.length());
+  return serialized;
+}
+
+void SerializeUuid(char** dest, credentio::Uuid uuid) {
+  const std::string bytes = uuid.ToProtoBytes();
+  if (bytes.size() == 16) {
+    memcpy(*dest, bytes.data(), 16);
+  } else {
+    memset(*dest, 0, 16);
+  }
+  *dest += 16;
+}
+
+std::string EncodeDescriptionBox(credentio::Uuid uuid,
+                                 std::optional<absl::string_view> label,
+                                 bool requestable) {
+  if (label.has_value()) {
+    CHECK(!absl::StrContains(*label, '\0'));
+    const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 +
+                                              label->size() +
+                                              /*label NUL terminator*/ 1;
+    std::string description_content(description_content_length, '\0');
+    char* content_cursor = description_content.data();
+
+    jumbf::SerializeUuid(&content_cursor, uuid);
+    uint8_t toggles = kDescriptionToggleLabelPresent;
+    if (requestable) {
+      toggles |= kDescriptionToggleRequestable;
+    }
+    riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor);
+    content_cursor += sizeof(uint8_t);
+    memcpy(content_cursor, label->data(), label->size());
+    content_cursor += label->size();
+    *content_cursor++ = '\0';
+    return WrapBox(description_content, kDescriptionBoxType);
+  }
+  const size_t description_content_length = /*uuid*/ 16 + /*toggles*/ 1 +
+                                            /*label NUL terminator*/ 1;
+  std::string description_content(description_content_length, '\0');
+  char* content_cursor = description_content.data();
+
+  jumbf::SerializeUuid(&content_cursor, uuid);
+  uint8_t toggles = kDescriptionToggleEmpty;
+  riegeli::WriteBigEndian<uint8_t>(toggles, content_cursor);
+  content_cursor += sizeof(uint8_t);
+  *content_cursor++ = '\0';
+  return WrapBox(description_content, kDescriptionBoxType);
+}
+
+std::string EncodeCborBox(absl::string_view contents) {
+  return WrapBox(contents, kCborBoxType);
+}
+
+std::string EncodeSuperBox(credentio::Uuid uuid,
+                           std::optional<absl::string_view> label,
+                           absl::Span<const absl::string_view> contents,
+                           bool requestable) {
+  return jumbf::WrapBox(
+      absl::StrCat(EncodeDescriptionBox(uuid, label, requestable),
+                   absl::StrJoin(contents, "")),
+      jumbf::kSuperBoxType);
+}
+
+absl::Cord CordFromConstexpr(absl::string_view data) {
+  return absl::MakeCordFromExternal(data, [](absl::string_view data) {});
+}
+
+}  // namespace jumbf
diff --git a/jumbf/test_utils.h b/jumbf/test_utils.h
new file mode 100644
index 0000000..5e1f13a
--- /dev/null
+++ b/jumbf/test_utils.h
@@ -0,0 +1,63 @@
+// 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_JUMBF_TEST_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_TEST_UTILS_H_
+
+#include <stdbool.h>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include "absl/strings/cord.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+// Wraps `content` in the basic box structure (section 4.3)
+std::string WrapBox(absl::string_view content, uint32_t tbox);
+
+// Wraps `content` in the basic box structure, using an LBox value of 0
+// (section 4.3)
+std::string WrapUndeterminedLengthBox(absl::string_view content, uint32_t tbox);
+
+// Serializes a 16 byte UUID in RFC 4122 (ISO / IEC 11578) format and advances
+// `dest`.
+void SerializeUuid(char** dest, credentio::Uuid uuid);
+
+// Serializes a description box (section A.3) with the given UUID and label.
+std::string EncodeDescriptionBox(credentio::Uuid uuid,
+                                 std::optional<absl::string_view> label,
+                                 bool requestable = false);
+
+// Serializes a CBOR box (section B.7) with the given contents.
+std::string EncodeCborBox(absl::string_view contents);
+
+// Serializes a superbox (section A.2) with the given UUID, label, and
+// contents.
+std::string EncodeSuperBox(credentio::Uuid uuid,
+                           std::optional<absl::string_view> label,
+                           absl::Span<const absl::string_view> contents,
+                           bool requestable = false);
+
+// This is only safe if the array backing `data` is a compile-time constant.
+absl::Cord CordFromConstexpr(absl::string_view data);
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_TEST_UTILS_H_
diff --git a/jumbf/uri.cc b/jumbf/uri.cc
new file mode 100644
index 0000000..2d37002
--- /dev/null
+++ b/jumbf/uri.cc
@@ -0,0 +1,105 @@
+// 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 "jumbf/uri.h"
+
+#include <string>
+#include <utility>
+#include <variant>
+
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_split.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/strip.h"
+#include "jumbf/box.h"
+
+namespace jumbf {
+namespace {
+
+absl::string_view GetLabel(const SuperBox& box) {
+  if (box.description.label.has_value()) {
+    return *box.description.label;
+  }
+  return "";
+}
+
+// Returns the first child superbox with the given label, or error if not
+// found.
+absl::StatusOr<const SuperBox*> GetChild(const SuperBox* box,
+                                         absl::string_view label) {
+  if (label == "." || label == "..") {
+    return absl::InvalidArgumentError("Unsupported path component");
+  }
+  for (const ContentBox& content : box->contents) {
+    if (std::holds_alternative<SuperBox>(content.payload)) {
+      const SuperBox& child = std::get<SuperBox>(content.payload);
+      if (GetLabel(child) == label) {
+        return &child;
+      }
+    }
+  }
+  return absl::NotFoundError("");
+}
+
+}  // namespace
+
+UriResolver UriResolver::WithSingleRootChild(const SuperBox* root_child) {
+  return UriResolver(root_child, absl::StrCat("/", GetLabel(*root_child), "/"));
+}
+
+UriResolver::UriResolver(const SuperBox* absl_nonnull root_box,
+                         std::string root_path)
+    : root_box_(*ABSL_DIE_IF_NULL(root_box)),
+      root_path_(std::move(root_path)) {}
+
+absl::StatusOr<std::string> UriResolver::GetAbsolutePathFromUri(
+    absl::string_view uri, absl::string_view current_path) {
+  if (!absl::ConsumePrefix(&uri, "self#jumbf=")) {
+    return absl::InvalidArgumentError("self#jumbf= prefix required");
+  }
+  return GetAbsolutePath(uri, current_path);
+}
+
+std::string UriResolver::GetAbsolutePath(absl::string_view path,
+                                         absl::string_view current_path) {
+  if (absl::StartsWith(path, "/")) {
+    // Absolute path.
+    return std::string(path);
+  }
+  // Relative path.
+  return absl::StrCat(current_path, "/", path);
+}
+
+absl::StatusOr<const SuperBox*> UriResolver::ResolvePath(
+    absl::string_view absolute_path) const {
+  const SuperBox* box = nullptr;
+  if (!absl::ConsumePrefix(&absolute_path, root_path_)) {
+    return absl::NotFoundError("");
+  }
+  box = &root_box_;
+  for (absl::string_view label : absl::StrSplit(absolute_path, '/')) {
+    ABSL_ASSIGN_OR_RETURN(auto child_box, GetChild(box, label));
+    box = std::move(child_box);
+  }
+  return box;
+}
+
+}  // namespace jumbf
diff --git a/jumbf/uri.h b/jumbf/uri.h
new file mode 100644
index 0000000..b161073
--- /dev/null
+++ b/jumbf/uri.h
@@ -0,0 +1,64 @@
+// 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_JUMBF_URI_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_URI_H_
+
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/box.h"
+
+namespace jumbf {
+
+class UriResolver {
+ public:
+  // Creates a new UriResolver where the root node has a single child.
+  static UriResolver WithSingleRootChild(const SuperBox* root_child);
+
+  // Resolves a JUMBF URI in one of these forms to an absolute path:
+  //  * `self#jumbf=/label1/label2/label3` --> `/label1/label2/label3`
+  //  * `self#jumbf=label3/label4` --> `/label1/label2/label3/label4`
+  //    (if `current_path` is `/label1/label2`)
+  //
+  // `current_path` must be an absolute path (e.g., `/label1/label2`).
+  static absl::StatusOr<std::string> GetAbsolutePathFromUri(
+      absl::string_view uri, absl::string_view current_path);
+
+  // Resolves a path in one of these forms to an absolute path:
+  //  * `/label1/label2/label3` -- absolute path
+  //  * `label3/label4` -- path gets `{current_path}/` prepended
+  // `current_path` must be an absolute path (e.g., `/label1/label2`).
+  static std::string GetAbsolutePath(absl::string_view path,
+                                     absl::string_view current_path);
+
+  // Resolves the given absolute path (produced by GetAbsolutePathFromUri() or
+  // GetAbsolutePath()) to a SuperBox. No "." or ".." path components are
+  // allowed.
+  absl::StatusOr<const SuperBox*> ResolvePath(
+      absl::string_view absolute_path) const;
+
+ private:
+  UriResolver(const SuperBox* absl_nonnull root_box, std::string root_path);
+
+  const SuperBox& root_box_;
+  std::string root_path_;  // Path to root_box_, with trailing slash.
+};
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_URI_H_
diff --git a/jumbf/uri_test.cc b/jumbf/uri_test.cc
new file mode 100644
index 0000000..095c494
--- /dev/null
+++ b/jumbf/uri_test.cc
@@ -0,0 +1,329 @@
+// 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 "jumbf/uri.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/no_destructor.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+
+constexpr absl::string_view kUuid1 = "DEADBEEF-F00D-BABE-B4DF-000000000001";
+
+DescriptionBox Desc(std::optional<absl::string_view> label) {
+  return DescriptionBox{
+      .type_uuid = credentio::Uuid::FromStringOrDie(kUuid1),
+      .label = std::move(label),
+  };
+}
+
+// Concise representation of a SuperBox tree for testing.
+struct TestSuperBox {
+  std::optional<absl::string_view> label;
+  std::vector<TestSuperBox> children;
+};
+
+// Creates a payload based on the provided label, backed by a singleton map.
+// Not thread-safe.
+absl::string_view TestPayload(std::optional<absl::string_view> label) {
+  static absl::NoDestructor<
+      absl::flat_hash_map<std::string, std::unique_ptr<std::string>>>
+      payloads;
+  std::string label_str = std::string(label.value_or(""));
+  if (auto it = payloads->find(label_str); it != payloads->end()) {
+    return *it->second;
+  }
+  std::string payload = absl::StrCat(label_str, "-payload");
+  auto [it, _] = payloads->insert(
+      std::make_pair(label_str, std::make_unique<std::string>(payload)));
+  return *it->second;
+}
+
+SuperBox MakeSuperBox(const TestSuperBox& test_box) {
+  SuperBox super_box = {.description = Desc(test_box.label)};
+  if (test_box.children.empty()) {
+    super_box.contents.push_back(
+        ContentBox{.payload = CborBox{.payload = TestPayload(test_box.label)}});
+  }
+  for (const TestSuperBox& child : test_box.children) {
+    super_box.contents.push_back(ContentBox{.payload = MakeSuperBox(child)});
+  }
+  return super_box;
+}
+
+absl::StatusOr<const SuperBox*> Get(const UriResolver& resolver,
+                                    absl::string_view uri,
+                                    absl::string_view current_path) {
+  ABSL_ASSIGN_OR_RETURN(auto absolute_path,
+                        resolver.GetAbsolutePathFromUri(uri, current_path));
+  return resolver.ResolvePath(absolute_path);
+}
+
+// Predicate matcher for a SuperBox* with a single CBOR content box.
+MATCHER_P(HasCborPayload, value, "") {
+  return arg->contents.size() == 1 &&
+         std::holds_alternative<CborBox>(arg->contents[0].payload) &&
+         std::get<CborBox>(arg->contents[0].payload).payload == value;
+}
+
+TEST(UriTest, RelativePaths) {
+  SuperBox root_box = MakeSuperBox(TestSuperBox{
+      .label = "label1",
+      .children = {TestSuperBox{
+          .label = "label2",
+          .children = {TestSuperBox{.label = std::nullopt},
+                       TestSuperBox{.label = "label3"},
+                       TestSuperBox{.label = "label4"},
+                       TestSuperBox{
+                           .label = "label5",
+                           .children = {TestSuperBox{.label = "label6"}},
+                       }}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(Get(resolver, "self#jumbf=label3", "/label1/label2"),
+              IsOkAndHolds(HasCborPayload("label3-payload")));
+  EXPECT_THAT(Get(resolver, "self#jumbf=label4", "/label1/label2"),
+              IsOkAndHolds(HasCborPayload("label4-payload")));
+  EXPECT_THAT(Get(resolver, "self#jumbf=label5/label6", "/label1/label2"),
+              IsOkAndHolds(HasCborPayload("label6-payload")));
+
+  EXPECT_THAT(Get(resolver, "self#jumbf=label2", "/label1/label2"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(Get(resolver, "self#jumbf=label4/label5", "/label1/label2"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(Get(resolver, "self#jumbf=label5/label6/", "/label1/label2"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(
+      Get(resolver, "self#jumbf=label5/label6/label7", "/label1/label2"),
+      StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(UriTest, AbsolutePaths) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = std::nullopt},
+                                    TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(
+      Get(resolver, "self#jumbf=/label1/label2/label3", "/label1/label2"),
+      IsOkAndHolds(HasCborPayload("label3-payload")));
+  EXPECT_THAT(Get(resolver, "self#jumbf=/label1/label2/label4",
+                  "/label1/label2/label3"),
+              IsOkAndHolds(HasCborPayload("label4-payload")));
+
+  EXPECT_THAT(Get(resolver, "self#jumbf=/", "/label1/label2"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(Get(resolver, "self#jumbf=/label3", "/label1/label2"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(Get(resolver, "self#jumbf=/label1/label2/label3/label5",
+                  "/label1/label2"),
+              StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(UriTest, EmptyPathNotFound) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(Get(resolver, "self#jumbf=", "/label1/label2"),
+              StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(UriTest, AbsolutePathWrongRootPrefix) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(Get(resolver, "self#jumbf=/somethingelse/label2/label3",
+                  "/label1/label2"),
+              StatusIs(absl::StatusCode::kNotFound));
+}
+
+// No self#jumbf= prefix.
+TEST(UriTest, NoSelfPrefix) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(Get(resolver, "/label1/label2/label3", "/label1/label2"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(UriTest, DotPathComponentsNotSupported) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(Get(resolver, "self#jumbf=/label1/label2/label3/../label4",
+                  "/label1/label2"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(Get(resolver, "self#jumbf=../label1/label2/label3/label4",
+                  "/label1/label2"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(
+      Get(resolver, "self#jumbf=/label1/label2/./label3", "/label1/label2"),
+      StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ResolvePathTest, AbsolutePaths) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = std::nullopt},
+                                    TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(resolver.ResolvePath("/label1/label2/label3"),
+              IsOkAndHolds(HasCborPayload("label3-payload")));
+  EXPECT_THAT(resolver.ResolvePath("/label1/label2/label4"),
+              IsOkAndHolds(HasCborPayload("label4-payload")));
+
+  EXPECT_THAT(resolver.ResolvePath("/"), StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(resolver.ResolvePath("/label3"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(resolver.ResolvePath("/label1/label2/label3/label5"),
+              StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(ResolvePathTest, EmptyPathNotFound) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(resolver.ResolvePath(""), StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(ResolvePathTest, AbsolutePathWrongRootPrefix) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(resolver.ResolvePath("/somethingelse/label2/label3"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(resolver.ResolvePath("self#jumbf=/label1/label2/label3"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(resolver.ResolvePath("label1/label2/label3"),
+              StatusIs(absl::StatusCode::kNotFound));
+}
+
+TEST(ResolvePathTest, DotPathComponentsNotSupported) {
+  SuperBox root_box = MakeSuperBox(
+      TestSuperBox{.label = "label1",
+                   .children = {TestSuperBox{
+                       .label = "label2",
+                       .children = {TestSuperBox{.label = "label3"},
+                                    TestSuperBox{.label = "label4"}}}}});
+
+  UriResolver resolver = UriResolver::WithSingleRootChild(&root_box);
+  EXPECT_THAT(resolver.ResolvePath("/label1/label2/label3/../label4"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(resolver.ResolvePath("../label1/label2/label3/label4"),
+              StatusIs(absl::StatusCode::kNotFound));
+  EXPECT_THAT(resolver.ResolvePath("/label1/label2/./label3"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(GetAbsolutePath, RelativePaths) {
+  EXPECT_THAT(UriResolver::GetAbsolutePath("label3", "/label1/label2"),
+              Eq("/label1/label2/label3"));
+  EXPECT_THAT(UriResolver::GetAbsolutePath("label3/label4", "/label1/label2"),
+              Eq("/label1/label2/label3/label4"));
+}
+
+TEST(GetAbsolutePath, AbsolutePaths) {
+  EXPECT_THAT(UriResolver::GetAbsolutePath("/label3", "/label1/label2"),
+              Eq("/label3"));
+  EXPECT_THAT(UriResolver::GetAbsolutePath("/label3/label4", "/label1/label2"),
+              Eq("/label3/label4"));
+}
+
+TEST(GetAbsolutePathFromUri, RelativePaths) {
+  EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("self#jumbf=label3",
+                                                  "/label1/label2"),
+              IsOkAndHolds("/label1/label2/label3"));
+  EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("self#jumbf=label3/label4",
+                                                  "/label1/label2"),
+              IsOkAndHolds("/label1/label2/label3/label4"));
+}
+
+TEST(GetAbsolutePathFromUri, AbsolutePaths) {
+  EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("self#jumbf=/label3",
+                                                  "/label1/label2"),
+              IsOkAndHolds("/label3"));
+  EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("self#jumbf=/label3/label4",
+                                                  "/label1/label2"),
+              IsOkAndHolds("/label3/label4"));
+}
+
+TEST(GetAbsolutePathFromUri, WrongPrefix) {
+  EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("/label3", "/label1/label2"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(UriResolver::GetAbsolutePathFromUri("https://google.com/label3",
+                                                  "/label1/label2"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+}  // namespace
+}  // namespace jumbf
diff --git a/jumbf/utils.cc b/jumbf/utils.cc
new file mode 100644
index 0000000..97e1555
--- /dev/null
+++ b/jumbf/utils.cc
@@ -0,0 +1,145 @@
+// 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 "jumbf/utils.h"
+
+#include <sys/types.h>
+
+#include <cstdint>
+#include <utility>
+#include <variant>
+
+#include "absl/functional/overload.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/strip.h"
+#include "jumbf/box.h"
+#include "jumbf/constants.h"
+#include "jumbf/internal/serialization_utils.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+namespace {
+
+// Consumes the header of a JUMBF Box and returns the TBox.
+//
+// The header is a 4 byte unsigned integer (LBox) followed by a 4 byte unsigned
+// integer (TBox). If the first integer is 1, it's followed by a 8 byte unsigned
+// integer (XLBox).
+absl::StatusOr<uint32_t> ConsumeHeader(absl::string_view* payload) {
+  ABSL_ASSIGN_OR_RETURN(auto lbox,
+                        jumbf_internal::ConsumeInteger<uint32_t>(payload));
+  ABSL_ASSIGN_OR_RETURN(auto tbox,
+                        jumbf_internal::ConsumeInteger<uint32_t>(payload));
+  if (lbox == 1) {
+    ABSL_RETURN_IF_ERROR(
+        jumbf_internal::ConsumeBytes(payload, sizeof(uint64_t)).status());
+  }
+  return tbox;
+}
+
+}  // namespace
+
+absl::StatusOr<absl::string_view> StripBoxHeaders(absl::string_view raw_bytes) {
+  ABSL_ASSIGN_OR_RETURN(auto lbox,
+                        jumbf_internal::ConsumeInteger<uint32_t>(&raw_bytes));
+  ABSL_RETURN_IF_ERROR(
+      jumbf_internal::ConsumeBytes(&raw_bytes, sizeof(uint32_t)).status());
+  if (lbox == 1) {
+    ABSL_RETURN_IF_ERROR(
+        jumbf_internal::ConsumeBytes(&raw_bytes, sizeof(uint64_t)).status());
+  }
+  return raw_bytes;
+}
+
+absl::StatusOr<bool> HasDescriptionBoxMatching(absl::string_view raw_bytes,
+                                               const credentio::Uuid& uuid,
+                                               uint8_t toggles_mask,
+                                               absl::string_view label) {
+  // Super Box Header
+  ABSL_ASSIGN_OR_RETURN(auto super_tbox, ConsumeHeader(&raw_bytes));
+  if (super_tbox != kSuperBoxType) {
+    return false;
+  }
+
+  // Description Box Header
+  ABSL_ASSIGN_OR_RETURN(auto description_tbox, ConsumeHeader(&raw_bytes));
+  if (description_tbox != kDescriptionBoxType) {
+    return false;
+  }
+
+  // Description Box UUID
+  if (raw_bytes.size() < 16) {
+    return absl::OutOfRangeError("Description Box UUID is missing.");
+  }
+  ABSL_ASSIGN_OR_RETURN(auto description_uuid, credentio::Uuid::FromProtoBytes(
+                                                   raw_bytes.substr(0, 16)));
+  raw_bytes.remove_prefix(16);
+  if (std::move(description_uuid) != uuid) {
+    return false;
+  }
+
+  // Description Box Toggles
+  ABSL_ASSIGN_OR_RETURN(auto toggles,
+                        jumbf_internal::ConsumeInteger<uint8_t>(&raw_bytes));
+  if ((toggles & toggles_mask) != toggles_mask) {
+    return false;
+  }
+
+  // Expected Label
+  if (!absl::ConsumePrefix(&raw_bytes, label)) {
+    return false;
+  }
+
+  // Ensure the next character is the null terminator
+  ABSL_ASSIGN_OR_RETURN(auto next_byte,
+                        jumbf_internal::ConsumeInteger<uint8_t>(&raw_bytes));
+  return next_byte == 0x00;
+}
+
+int GetContextBoxSize(const ContentBox& box) {
+  int content_size = 0;
+  std::visit(
+      absl::Overload{
+          [&](const UnknownBox& arg) { content_size = arg.payload.size(); },
+          [&](const CborBox& arg) { content_size = arg.payload.size(); },
+          [&](const SerializedSuperbox& arg) {
+            content_size = arg.serialized.size();
+          },
+          [&](const SuperBox& arg) { content_size = arg.raw_bytes.size(); },
+          [&](const EmbeddedFileDescriptionBox& arg) {
+            // See section B.6.2
+            // Always include a byte for toggles
+            content_size = 1;
+            // Add media type size + 1 for null terminator
+            content_size += arg.media_type.size() + 1;
+            if (arg.file_name.has_value()) {
+              // Add file name size + 1 for null terminator
+              content_size += arg.file_name->size() + 1;
+            }
+          },
+          [&](const BinaryDataBox& arg) { content_size = arg.payload.size(); },
+          [&](const C2paSaltBox& arg) { content_size = arg.salt.size(); },
+          [&](const JsonBox& arg) { content_size = arg.payload.size(); },
+          [&](const UuidBox& arg) { content_size = arg.payload.size(); },
+      },
+      box.payload);
+  return content_size;
+}
+
+}  // namespace jumbf
diff --git a/jumbf/utils.h b/jumbf/utils.h
new file mode 100644
index 0000000..de09428
--- /dev/null
+++ b/jumbf/utils.h
@@ -0,0 +1,50 @@
+// 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_JUMBF_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_JUMBF_UTILS_H_
+
+#include <cstdint>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "jumbf/box.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+
+// Remove the JUMBF box header (LBox, TBox, XLBox). Returns the box content. For
+// superboxes, this is both the description and the contents.
+absl::StatusOr<absl::string_view> StripBoxHeaders(absl::string_view raw_bytes);
+
+// Checks if the top-level JUMBF box within the supplied bytes has a description
+// box matching the supplied UUID, toggles and label by inspecting the raw
+// bytes.
+//
+// Note: ConsumeSuperBox() function is not used because it requires passing a
+// complete JUMBF box which may not be available for every use. (JPEGs possibly
+// need to spread the full data across multiple segments with it being
+// inadvisable to force them to combine prior to this check).
+absl::StatusOr<bool> HasDescriptionBoxMatching(absl::string_view raw_bytes,
+                                               const credentio::Uuid& uuid,
+                                               uint8_t toggles_mask,
+                                               absl::string_view label);
+
+// Returns the size of the box payload.
+int GetContextBoxSize(const ContentBox& box);
+
+}  // namespace jumbf
+
+#endif  // THIRD_PARTY_CREDENTIO_JUMBF_UTILS_H_
diff --git a/jumbf/utils_test.cc b/jumbf/utils_test.cc
new file mode 100644
index 0000000..00fb275
--- /dev/null
+++ b/jumbf/utils_test.cc
@@ -0,0 +1,441 @@
+// 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 "jumbf/utils.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/numeric/int128.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.h"
+#include "jumbf/parse.h"
+#include "jumbf/test_utils.h"
+#include "riegeli/endian/endian_writing.h"
+#include "uuid/uuid.h"
+
+namespace jumbf {
+namespace {
+
+void WriteUint32NetworkOrder(uint32_t value, std::vector<uint8_t>* bytes) {
+  bytes->push_back(static_cast<uint8_t>((value >> 24) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>((value >> 16) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>((value >> 8) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>(value & 0xFF));
+}
+
+void WriteUint64NetworkOrder(uint64_t value, std::vector<uint8_t>* bytes) {
+  bytes->push_back(static_cast<uint8_t>((value >> 56) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>((value >> 48) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>((value >> 40) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>((value >> 32) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>((value >> 24) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>((value >> 16) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>((value >> 8) & 0xFF));
+  bytes->push_back(static_cast<uint8_t>(value & 0xFF));
+}
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+inline constexpr uint32_t kUnknownBoxType = 0x1111'1111;
+inline constexpr credentio::Uuid kUuid1(
+    credentio::Uuid::FromStringOrDie("11111111-1111-1111-1111-111111111111"));
+inline constexpr absl::uint128 kUuid1_uint128 =
+    absl::MakeUint128(0x1111111111111111, 0x1111111111111111);
+inline constexpr absl::uint128 kUuid2_uint128 =
+    absl::MakeUint128(0x2222222222222222, 0x2222222222222222);
+inline constexpr uint8_t kToggles1 = 0b00001111;
+inline constexpr uint8_t kToggles2 = 0b11110000;
+inline constexpr absl::string_view kLabel1 = "expected";
+inline constexpr absl::string_view kLabel2 = "unexpected";
+
+TEST(UtilsTest, TrimJumbfSuperBoxHeader) {
+  std::string input = WrapBox("content", kSuperBoxType);
+  EXPECT_THAT(StripBoxHeaders(input), IsOkAndHolds("content"));
+}
+
+TEST(UtilsTest, TrimEmptyJumbfSuperBoxHeader) {
+  EXPECT_THAT(StripBoxHeaders(""), StatusIs(absl::StatusCode::kOutOfRange));
+}
+
+struct BoxData {
+  std::optional<uint32_t> super_lbox = std::nullopt;
+  std::optional<uint32_t> super_tbox = std::nullopt;
+  std::optional<uint64_t> super_xlbox = std::nullopt;
+  std::optional<uint32_t> desc_lbox = std::nullopt;
+  std::optional<uint32_t> desc_tbox = std::nullopt;
+  std::optional<uint64_t> desc_xlbox = std::nullopt;
+
+  std::optional<absl::uint128> uuid = std::nullopt;
+
+  std::optional<uint8_t> toggles = std::nullopt;
+
+  std::optional<absl::string_view> label = std::nullopt;
+  bool null_terminator = true;
+};
+
+std::string CreatePayload(const BoxData& box_data) {
+  std::vector<uint8_t> bytes;
+  if (box_data.super_lbox.has_value()) {
+    WriteUint32NetworkOrder(*box_data.super_lbox, &bytes);
+  }
+  if (box_data.super_tbox.has_value()) {
+    WriteUint32NetworkOrder(*box_data.super_tbox, &bytes);
+  }
+  if (box_data.super_xlbox.has_value()) {
+    WriteUint64NetworkOrder(*box_data.super_xlbox, &bytes);
+  }
+  if (box_data.desc_lbox.has_value()) {
+    WriteUint32NetworkOrder(*box_data.desc_lbox, &bytes);
+  }
+  if (box_data.desc_tbox.has_value()) {
+    WriteUint32NetworkOrder(*box_data.desc_tbox, &bytes);
+  }
+  if (box_data.desc_xlbox.has_value()) {
+    WriteUint64NetworkOrder(*box_data.desc_xlbox, &bytes);
+  }
+  if (box_data.uuid.has_value()) {
+    WriteUint64NetworkOrder(absl::Uint128High64(*box_data.uuid), &bytes);
+    WriteUint64NetworkOrder(absl::Uint128Low64(*box_data.uuid), &bytes);
+  }
+  if (box_data.toggles.has_value()) {
+    bytes.push_back(*box_data.toggles);
+  }
+  if (box_data.label.has_value()) {
+    bytes.insert(bytes.end(), box_data.label->begin(), box_data.label->end());
+  }
+  if (box_data.null_terminator) {
+    bytes.push_back(0x00);
+  }
+  return std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size());
+}
+
+// Serializes an absl::string_view to a char array, followed by a null
+// terminator.
+void SerializeStringWithNull(char** dest, absl::string_view str) {
+  memcpy(*dest, str.data(), str.size());
+  *dest += str.size();
+  **dest = 0x00;
+  *dest += 1;
+}
+
+TEST(HasDescriptionBoxMatchingTest, NoData) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({}), kUuid1, kToggles1, kLabel1),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               HasSubstr("not enough input bytes remaining; have 1 need 4")));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsSuperBoxLbox) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1}), kUuid1,
+                                kToggles1, kLabel1),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               HasSubstr("not enough input bytes remaining; have 1 need 4")));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsSuperBoxTbox) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(
+          CreatePayload({.super_lbox = 1, .super_tbox = 1}), kUuid1, kToggles1,
+          kLabel1),
+      StatusIs(
+          absl::StatusCode::kOutOfRange,
+          HasSubstr("ran out of input bytes; 8 bytes requested, 1 remaining")));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsSuperBoxXlboxIncorrectTbox) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(
+          CreatePayload({.super_lbox = 1, .super_tbox = 1, .super_xlbox = 1}),
+          kUuid1, kToggles1, kLabel1),
+      IsOkAndHolds(false));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsSuperBoxTboxCorrect) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(
+          CreatePayload(
+              {.super_lbox = 1, .super_tbox = kSuperBoxType, .super_xlbox = 1}),
+          kUuid1, kToggles1, kLabel1),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               HasSubstr("not enough input bytes remaining; have 1 need 4")));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsDescriptorBoxLbox) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1}),
+                                kUuid1, kToggles1, kLabel1),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               HasSubstr("not enough input bytes remaining; have 1 need 4")));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsDescriptorBoxTbox) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = 1}),
+                                kUuid1, kToggles1, kLabel1),
+      StatusIs(
+          absl::StatusCode::kOutOfRange,
+          HasSubstr("ran out of input bytes; 8 bytes requested, 1 remaining")));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsDescriptorBoxXlboxIncorrectTbox) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = 1,
+                                               .desc_xlbox = 1}),
+                                kUuid1, kToggles1, kLabel1),
+      IsOkAndHolds(false));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsDescriptorBoxTboxCorrect) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = kDescriptionBoxType,
+                                               .desc_xlbox = 1}),
+                                kUuid1, kToggles1, kLabel1),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               HasSubstr("Description Box UUID is missing.")));
+}
+
+TEST(HasDescriptionBoxMatchingTest,
+     ContainsDescriptorBoxTboxCorrectEmptyUuidPayload) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = kDescriptionBoxType,
+                                               .desc_xlbox = 1,
+                                               .null_terminator = false}),
+                                kUuid1, kToggles1, kLabel1),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               HasSubstr("Description Box UUID is missing.")));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsUuidIncorrect) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = kDescriptionBoxType,
+                                               .desc_xlbox = 1,
+                                               .uuid = kUuid2_uint128}),
+                                kUuid1, kToggles1, kLabel1),
+      IsOkAndHolds(false));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsUuidCorrect) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = kDescriptionBoxType,
+                                               .desc_xlbox = 1,
+                                               .uuid = kUuid1_uint128,
+                                               .null_terminator = false}),
+                                kUuid1, kToggles1, kLabel1),
+      StatusIs(absl::StatusCode::kOutOfRange,
+               HasSubstr("not enough input bytes remaining; have 0 need 1")));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsTogglesIncorrect) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = kDescriptionBoxType,
+                                               .desc_xlbox = 1,
+                                               .uuid = kUuid1_uint128,
+                                               .toggles = kToggles2}),
+                                kUuid1, kToggles1, kLabel1),
+      IsOkAndHolds(false));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsTogglesCorrect) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = kDescriptionBoxType,
+                                               .desc_xlbox = 1,
+                                               .uuid = kUuid1_uint128,
+                                               .toggles = kToggles1}),
+                                kUuid1, kToggles1, kLabel1),
+      IsOkAndHolds(false));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsWrongLabel) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = kDescriptionBoxType,
+                                               .desc_xlbox = 1,
+                                               .uuid = kUuid1_uint128,
+                                               .toggles = kToggles1,
+                                               .label = kLabel2}),
+                                kUuid1, kToggles1, kLabel1),
+      IsOkAndHolds(false));
+}
+
+TEST(HasDescriptionBoxMatchingTest, ContainsManifest) {
+  EXPECT_THAT(
+      HasDescriptionBoxMatching(CreatePayload({.super_lbox = 1,
+                                               .super_tbox = kSuperBoxType,
+                                               .super_xlbox = 1,
+                                               .desc_lbox = 1,
+                                               .desc_tbox = kDescriptionBoxType,
+                                               .desc_xlbox = 1,
+                                               .uuid = kUuid1_uint128,
+                                               .toggles = kToggles1,
+                                               .label = kLabel1}),
+                                kUuid1, kToggles1, kLabel1),
+      IsOkAndHolds(true));
+}
+
+TEST(GetContextBoxSizeTest, GetContextBoxSize) {
+  std::string payload = "test";
+  SuperBoxBuilder builder(
+      kUuid1, {.requestable_iff_label = true, .label = absl::Cord("main")});
+  size_t efdb_payload_length = 0;
+  {
+    constexpr uint8_t kToggles = jumbf::kEfdbToggleFileNamePresent;
+    constexpr absl::string_view kFakeMediatype = "media/MyFormat";
+    constexpr absl::string_view kFakePath = "path/to/some/file";
+
+    efdb_payload_length =
+        1 /*toggles*/ + kFakeMediatype.size() + 1 + kFakePath.size() + 1;
+    std::string payload(efdb_payload_length, 'A');
+    char* cursor = payload.data();
+    riegeli::WriteBigEndian<uint8_t>(kToggles, cursor);
+    cursor += sizeof(uint8_t);
+    SerializeStringWithNull(&cursor, kFakeMediatype);
+    SerializeStringWithNull(&cursor, kFakePath);
+    ABSL_ASSERT_OK(builder.AddContent(kEmbeddedFileDescriptionBoxType,
+                                      absl::Cord(payload)));
+  }
+  ABSL_ASSERT_OK(builder.AddContent(kUuidBoxType, absl::Cord(payload)));
+  ABSL_ASSERT_OK(builder.AddContent(kJsonBoxType, absl::Cord(payload)));
+  ABSL_ASSERT_OK(builder.AddContent(kC2paSaltBoxType, absl::Cord(payload)));
+  ABSL_ASSERT_OK(builder.AddContent(kBinaryDataBoxType, absl::Cord(payload)));
+  size_t superbox_payload_size = 0;
+  size_t child_payload_size = 0;
+  {
+    SuperBoxBuilder child_builder(
+        kUuid1, {.requestable_iff_label = true, .label = absl::Cord("child")});
+    ABSL_ASSERT_OK(
+        child_builder.AddContent(kSuperBoxType, absl::Cord(payload)));
+    auto child_box_or = std::move(child_builder).Finalize();
+    ABSL_ASSERT_OK(child_box_or);
+    BuiltSuperBox child_box = std::move(*child_box_or);
+    absl::Cord child_cord = std::move(child_box).AsCord();
+    superbox_payload_size = child_cord.size();
+    absl::string_view child_view = child_cord.Flatten();
+    auto parsed_child_box_or =
+        ConsumeSuperBox(&child_view, /*recursion_limit=*/0);
+    ABSL_ASSERT_OK(parsed_child_box_or);
+    SuperBox parsed_child_box = std::move(*parsed_child_box_or);
+    child_payload_size = parsed_child_box.contents[0]
+                             .Get<SerializedSuperbox>()
+                             .serialized.size();
+    ABSL_ASSERT_OK(builder.AddChild(child_cord));
+  }
+  ABSL_ASSERT_OK(builder.AddContent(kCborBoxType, absl::Cord(payload)));
+  ABSL_ASSERT_OK(builder.AddContent(kUnknownBoxType, absl::Cord(payload)));
+  auto built_superbox_or = std::move(builder).Finalize();
+  ABSL_ASSERT_OK(built_superbox_or);
+  BuiltSuperBox built_superbox = std::move(*built_superbox_or);
+  absl::Cord superbox_cord = std::move(built_superbox).AsCord();
+  absl::string_view superbox_view = superbox_cord.Flatten();
+  auto box_or = ConsumeSuperBox(&superbox_view, /*recursion_limit=*/1);
+  ABSL_ASSERT_OK(box_or);
+  SuperBox box = std::move(*box_or);
+
+  EXPECT_TRUE(box.contents[0].Holds<UnknownBox>());
+  EXPECT_THAT(GetContextBoxSize(box.contents[0]), payload.size());
+  EXPECT_TRUE(box.contents[1].Holds<CborBox>());
+  EXPECT_THAT(GetContextBoxSize(box.contents[1]), payload.size());
+  EXPECT_TRUE(box.contents[2].Holds<SuperBox>());
+  EXPECT_THAT(GetContextBoxSize(box.contents[2]), superbox_payload_size);
+  SuperBox serialized_superbox = box.contents[2].Get<SuperBox>();
+  EXPECT_TRUE(serialized_superbox.contents[0].Holds<SerializedSuperbox>());
+  EXPECT_THAT(GetContextBoxSize(serialized_superbox.contents[0]),
+              child_payload_size);
+  EXPECT_TRUE(box.contents[3].Holds<BinaryDataBox>());
+  EXPECT_THAT(GetContextBoxSize(box.contents[3]), payload.size());
+  EXPECT_TRUE(box.contents[4].Holds<C2paSaltBox>());
+  EXPECT_THAT(GetContextBoxSize(box.contents[4]), payload.size());
+  EXPECT_TRUE(box.contents[5].Holds<JsonBox>());
+  EXPECT_THAT(GetContextBoxSize(box.contents[5]), payload.size());
+  EXPECT_TRUE(box.contents[6].Holds<UuidBox>());
+  EXPECT_THAT(GetContextBoxSize(box.contents[6]), payload.size());
+  EXPECT_TRUE(box.contents[7].Holds<EmbeddedFileDescriptionBox>());
+  EXPECT_THAT(GetContextBoxSize(box.contents[7]), efdb_payload_length);
+}
+
+TEST(TestUtilsTest, SerializeUuid) {
+  char buf[32] = {'\xff', '\xff', '\xff', '\xff', '\xff', '\xff', '\xff',
+                  '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', '\xff',
+                  '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', '\xff',
+                  '\xff', '\xff', '\xff', '\xff', '\xff', '\xff', '\xff',
+                  '\xff', '\xff', '\xff', '\xff'};
+  char* cursor = buf;
+  SerializeUuid(&cursor, credentio::Uuid::kInvalid);
+  EXPECT_EQ(cursor - buf, 16);
+  for (int i = 0; i < 16; ++i) {
+    EXPECT_EQ(buf[i], '\0');
+  }
+
+  SerializeUuid(&cursor, kUuid1);
+  EXPECT_EQ(cursor - buf, 32);
+  EXPECT_EQ(std::string(buf + 16, 16), kUuid1.ToProtoBytes());
+}
+
+}  // namespace
+}  // namespace jumbf
diff --git a/kokoro/gcp_ubuntu/build_and_test.sh b/kokoro/gcp_ubuntu/build_and_test.sh
new file mode 100755
index 0000000..da5757e
--- /dev/null
+++ b/kokoro/gcp_ubuntu/build_and_test.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+# 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.
+#
+
+set -e
+
+# Navigate to the repository root
+# KOKORO_ARTIFACTS_DIR is set to /tmpfs/src by default in the Kokoro Ubuntu image.
+export KOKORO_ARTIFACTS_DIR="${KOKORO_ARTIFACTS_DIR:-/tmpfs/src}"
+cd "${KOKORO_ARTIFACTS_DIR}/git/staging"
+
+# Run Bazel build and test all targets
+bazel build ...
+bazel test ...
+
+# --- Run Gradle build and unit tests for C2PA Android SDK ---
+if [ -d "android" ] && [ -x "android/gradlew" ]; then
+  if ! java -version 2>&1 | grep -q "17\."; then
+    echo "Installing OpenJDK 17..."
+    sudo apt-get update || true
+    sudo apt-get install -y openjdk-17-jdk || true
+    export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
+    export PATH="${JAVA_HOME}/bin:${PATH}"
+  fi
+
+  cd android
+  ./gradlew assembleDebug testDebugUnitTest --no-daemon --stacktrace
+else
+  echo "No android/gradlew found; skipping Android Gradle build."
+fi
diff --git a/kokoro/gcp_ubuntu/continuous.cfg b/kokoro/gcp_ubuntu/continuous.cfg
new file mode 100644
index 0000000..b03c640
--- /dev/null
+++ b/kokoro/gcp_ubuntu/continuous.cfg
@@ -0,0 +1,8 @@
+# proto-file: google3/devtools/kokoro/config/proto/build.proto
+# proto-message: BuildConfig
+
+build_file: "staging/kokoro/gcp_ubuntu/build_and_test.sh"
+
+container_properties {
+  docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest"
+}
diff --git a/kokoro/gcp_ubuntu/presubmit.cfg b/kokoro/gcp_ubuntu/presubmit.cfg
new file mode 100644
index 0000000..b03c640
--- /dev/null
+++ b/kokoro/gcp_ubuntu/presubmit.cfg
@@ -0,0 +1,8 @@
+# proto-file: google3/devtools/kokoro/config/proto/build.proto
+# proto-message: BuildConfig
+
+build_file: "staging/kokoro/gcp_ubuntu/build_and_test.sh"
+
+container_properties {
+  docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest"
+}
diff --git a/proto/BUILD b/proto/BUILD
new file mode 100644
index 0000000..1a6731c
--- /dev/null
+++ b/proto/BUILD
@@ -0,0 +1,704 @@
+# 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.
+
+load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library")
+load("@protobuf//bazel:java_lite_proto_library.bzl", "java_lite_proto_library")
+load("@protobuf//bazel:java_proto_library.bzl", "java_proto_library")
+load("@protobuf//bazel:proto_library.bzl", "proto_library")
+load("//cbor:cbor_codegen.bzl", "cc_cbor_proto_library")
+
+package(default_visibility = ["//visibility:public"])
+
+proto_library(
+    name = "assurance_level_proto",
+    srcs = ["assurance_level.proto"],
+)
+
+cc_proto_library(
+    name = "assurance_level_cc_proto",
+    deps = [":assurance_level_proto"],
+)
+
+java_lite_proto_library(
+    name = "assurance_level_java_proto_lite",
+    deps = [":assurance_level_proto"],
+)
+
+proto_library(
+    name = "asset_type_proto",
+    srcs = ["asset_type.proto"],
+    deps = ["//cbor:tags_proto"],
+)
+
+cc_proto_library(
+    name = "asset_type_cc_proto",
+    deps = [":asset_type_proto"],
+)
+
+java_lite_proto_library(
+    name = "asset_type_java_proto_lite",
+    deps = [":asset_type_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "asset_type_cbor_proto",
+    srcs = [":asset_type_proto"],
+    deps = [":asset_type_cc_proto"],
+)
+
+proto_library(
+    name = "hashed_uri_proto",
+    srcs = ["hashed_uri.proto"],
+    deps = [
+        ":asset_type_proto",
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "hashed_uri_cc_proto",
+    deps = [":hashed_uri_proto"],
+)
+
+java_lite_proto_library(
+    name = "hashed_uri_java_proto_lite",
+    deps = [":hashed_uri_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "hashed_uri_cbor_proto",
+    srcs = [":hashed_uri_proto"],
+    deps = [":hashed_uri_cc_proto"],
+)
+
+proto_library(
+    name = "asset_types_assertion_proto",
+    srcs = ["asset_types_assertion.proto"],
+    deps = [
+        ":asset_type_proto",
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "asset_types_assertion_cc_proto",
+    deps = [":asset_types_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "asset_types_assertion_java_proto_lite",
+    deps = [":asset_types_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "asset_types_assertion_cbor_proto",
+    srcs = [":asset_types_assertion_proto"],
+    deps = [
+        ":asset_types_assertion_cc_proto",
+    ],
+)
+
+proto_library(
+    name = "asset_reference_assertion_proto",
+    srcs = ["asset_reference_assertion.proto"],
+    option_deps = ["@protobuf//:cpp_features_proto"],
+    deps = ["//cbor:tags_proto"],
+)
+
+cc_proto_library(
+    name = "asset_reference_assertion_cc_proto",
+    deps = [":asset_reference_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "asset_reference_assertion_java_proto_lite",
+    deps = [":asset_reference_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "asset_reference_assertion_cbor_proto",
+    srcs = [":asset_reference_assertion_proto"],
+    deps = [":asset_reference_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "generator_info_proto",
+    srcs = ["generator_info.proto"],
+    deps = [
+        ":hashed_uri_proto",
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "generator_info_cc_proto",
+    deps = [":generator_info_proto"],
+)
+
+java_lite_proto_library(
+    name = "generator_info_java_proto_lite",
+    deps = [":generator_info_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "generator_info_cbor_proto",
+    srcs = [":generator_info_proto"],
+    deps = [
+        ":generator_info_cc_proto",
+    ],
+)
+
+proto_library(
+    name = "actions_assertion_proto",
+    srcs = ["actions_assertion.proto"],
+    deps = [
+        ":generator_info_proto",
+        ":hashed_uri_proto",
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "actions_assertion_cc_proto",
+    deps = [":actions_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "actions_assertion_java_proto_lite",
+    deps = [":actions_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "actions_assertion_cbor_proto",
+    srcs = [":actions_assertion_proto"],
+    deps = [
+        ":actions_assertion_cc_proto",
+    ],
+)
+
+proto_library(
+    name = "thumbnail_assertion_proto",
+    srcs = ["thumbnail_assertion.proto"],
+)
+
+cc_proto_library(
+    name = "thumbnail_assertion_cc_proto",
+    deps = [":thumbnail_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "thumbnail_assertion_java_proto_lite",
+    deps = [":thumbnail_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "thumbnail_assertion_cbor_proto",
+    srcs = [":thumbnail_assertion_proto"],
+    deps = [":thumbnail_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "validation_status_proto",
+    srcs = ["validation_status.proto"],
+    deps = ["//cbor:tags_proto"],
+)
+
+cc_proto_library(
+    name = "validation_status_cc_proto",
+    deps = [":validation_status_proto"],
+)
+
+java_lite_proto_library(
+    name = "validation_status_java_proto_lite",
+    deps = [":validation_status_proto"],
+)
+
+java_proto_library(
+    name = "validation_status_java_proto",
+    deps = [":validation_status_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "validation_status_cbor_proto",
+    srcs = [":validation_status_proto"],
+    deps = [":validation_status_cc_proto"],
+)
+
+proto_library(
+    name = "bmff_based_hash_assertion_proto",
+    srcs = ["bmff_based_hash_assertion.proto"],
+    deps = [
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "bmff_based_hash_assertion_cc_proto",
+    deps = [":bmff_based_hash_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "bmff_based_hash_assertion_java_proto_lite",
+    deps = [":bmff_based_hash_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "bmff_based_hash_assertion_cbor_proto",
+    srcs = [":bmff_based_hash_assertion_proto"],
+    deps = [":bmff_based_hash_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "boxes_hash_assertion_proto",
+    srcs = ["boxes_hash_assertion.proto"],
+    deps = [
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "boxes_hash_assertion_cc_proto",
+    deps = [":boxes_hash_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "boxes_hash_assertion_java_proto_lite",
+    deps = [":boxes_hash_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "boxes_hash_assertion_cbor_proto",
+    srcs = [":boxes_hash_assertion_proto"],
+    deps = [":boxes_hash_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "collection_data_hash_assertion_proto",
+    srcs = ["collection_data_hash_assertion.proto"],
+    deps = [
+        ":asset_type_proto",
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "collection_data_hash_assertion_cc_proto",
+    deps = [":collection_data_hash_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "collection_data_hash_assertion_java_proto_lite",
+    deps = [":collection_data_hash_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "collection_data_hash_assertion_cbor_proto",
+    srcs = [":collection_data_hash_assertion_proto"],
+    deps = [
+        ":collection_data_hash_assertion_cc_proto",
+    ],
+)
+
+proto_library(
+    name = "data_hash_assertion_proto",
+    srcs = ["data_hash_assertion.proto"],
+    deps = ["//cbor:tags_proto"],
+)
+
+cc_proto_library(
+    name = "data_hash_assertion_cc_proto",
+    deps = [":data_hash_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "data_hash_assertion_java_proto_lite",
+    deps = [":data_hash_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "data_hash_assertion_cbor_proto",
+    srcs = [":data_hash_assertion_proto"],
+    deps = [":data_hash_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "signature_info_proto",
+    srcs = ["signature_info.proto"],
+    deps = [
+        ":assurance_level_proto",
+        "@protobuf//:timestamp_proto",
+    ],
+)
+
+java_proto_library(
+    name = "signature_info_java_proto",
+    deps = [":signature_info_proto"],
+)
+
+cc_proto_library(
+    name = "signature_info_cc_proto",
+    deps = [":signature_info_proto"],
+)
+
+java_lite_proto_library(
+    name = "signature_info_java_proto_lite",
+    deps = [":signature_info_proto"],
+)
+
+proto_library(
+    name = "multi_asset_hash_assertion_proto",
+    srcs = ["multi_asset_hash_assertion.proto"],
+    deps = [
+        ":hashed_uri_proto",
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "multi_asset_hash_assertion_cc_proto",
+    deps = [":multi_asset_hash_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "multi_asset_hash_assertion_java_proto_lite",
+    deps = [":multi_asset_hash_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "multi_asset_hash_assertion_cbor_proto",
+    srcs = [":multi_asset_hash_assertion_proto"],
+    deps = [
+        ":multi_asset_hash_assertion_cc_proto",
+    ],
+)
+
+proto_library(
+    name = "redacted_assertion_proto",
+    srcs = ["redacted_assertion.proto"],
+)
+
+cc_proto_library(
+    name = "redacted_assertion_cc_proto",
+    deps = [":redacted_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "redacted_assertion_java_proto_lite",
+    deps = [":redacted_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "redacted_assertion_cbor_proto",
+    srcs = [":redacted_assertion_proto"],
+    deps = [":redacted_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "metadata_assertion_proto",
+    srcs = ["metadata_assertion.proto"],
+    deps = [
+        "//cbor:tags_proto",
+        "@protobuf//:struct_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "metadata_assertion_cc_proto",
+    deps = [":metadata_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "metadata_assertion_java_proto_lite",
+    deps = [":metadata_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "metadata_assertion_cbor_proto",
+    srcs = [":metadata_assertion_proto"],
+    deps = [":metadata_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "ai_disclosure_assertion_proto",
+    srcs = ["ai_disclosure_assertion.proto"],
+    deps = [
+        "//cbor:tags_proto",
+        "@protobuf//:struct_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "ai_disclosure_assertion_cc_proto",
+    deps = [":ai_disclosure_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "ai_disclosure_assertion_java_proto_lite",
+    deps = [":ai_disclosure_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "ai_disclosure_assertion_cbor_proto",
+    srcs = [":ai_disclosure_assertion_proto"],
+    deps = [":ai_disclosure_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "environmental_sustainability_assertion_proto",
+    srcs = ["environmental_sustainability_assertion.proto"],
+    deps = [
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "environmental_sustainability_assertion_cc_proto",
+    deps = [":environmental_sustainability_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "environmental_sustainability_assertion_java_proto_lite",
+    deps = [":environmental_sustainability_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "environmental_sustainability_assertion_cbor_proto",
+    srcs = [":environmental_sustainability_assertion_proto"],
+    deps = [":environmental_sustainability_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "repository_receipt_assertion_proto",
+    srcs = ["repository_receipt_assertion.proto"],
+    deps = [
+        "//cbor:tags_proto",
+        "@protobuf//:struct_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "repository_receipt_assertion_cc_proto",
+    deps = [":repository_receipt_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "repository_receipt_assertion_java_proto_lite",
+    deps = [":repository_receipt_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "repository_receipt_assertion_cbor_proto",
+    srcs = [":repository_receipt_assertion_proto"],
+    deps = [":repository_receipt_assertion_cc_proto"],
+)
+
+proto_library(
+    name = "soft_binding_assertion_proto",
+    srcs = ["soft_binding_assertion.proto"],
+    deps = [
+        "//cbor:tags_proto",
+        "@protobuf//:struct_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "soft_binding_assertion_cc_proto",
+    deps = [":soft_binding_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "soft_binding_assertion_cbor_proto",
+    srcs = [":soft_binding_assertion_proto"],
+    deps = [
+        ":soft_binding_assertion_cc_proto",
+    ],
+)
+
+proto_library(
+    name = "assertion_proto",
+    srcs = ["assertion.proto"],
+    deps = [
+        ":actions_assertion_proto",
+        ":ai_disclosure_assertion_proto",
+        ":bmff_based_hash_assertion_proto",
+        ":boxes_hash_assertion_proto",
+        ":collection_data_hash_assertion_proto",
+        ":data_hash_assertion_proto",
+        ":environmental_sustainability_assertion_proto",
+        ":ingredient_assertion_proto",
+        ":metadata_assertion_proto",
+        ":multi_asset_hash_assertion_proto",
+        ":redacted_assertion_proto",
+        ":repository_receipt_assertion_proto",
+        ":soft_binding_assertion_proto",
+        ":thumbnail_assertion_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "assertion_cc_proto",
+    deps = [":assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "assertion_java_proto_lite",
+    deps = [":assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "assertion_cbor_proto",
+    srcs = [":assertion_proto"],
+    deps = [
+        ":assertion_cc_proto",
+        ":ingredient_assertion_cbor_proto",
+    ],
+)
+
+proto_library(
+    name = "ingredient_assertion_proto",
+    srcs = ["ingredient_assertion.proto"],
+    deps = [
+        ":asset_type_proto",
+        ":hashed_uri_proto",
+        ":validation_status_proto",
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "ingredient_assertion_cc_proto",
+    deps = [":ingredient_assertion_proto"],
+)
+
+java_lite_proto_library(
+    name = "ingredient_assertion_java_proto_lite",
+    deps = [":ingredient_assertion_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "ingredient_assertion_cbor_proto",
+    srcs = [":ingredient_assertion_proto"],
+    deps = [
+        ":ingredient_assertion_cc_proto",
+    ],
+)
+
+proto_library(
+    name = "cose_verification_result_proto",
+    srcs = ["cose_verification_result.proto"],
+    deps = [
+        ":signature_info_proto",
+        ":validation_status_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "cose_verification_result_cc_proto",
+    deps = [":cose_verification_result_proto"],
+)
+
+java_lite_proto_library(
+    name = "cose_verification_result_java_proto_lite",
+    deps = [":cose_verification_result_proto"],
+)
+
+proto_library(
+    name = "manifest_proto",
+    srcs = ["manifest.proto"],
+    option_deps = ["@protobuf//:cpp_features_proto"],
+    deps = [
+        ":assertion_proto",
+        ":generator_info_proto",
+        ":hashed_uri_proto",
+        ":signature_info_proto",
+        ":validation_status_proto",
+        "//cbor:tags_proto",
+    ],
+)
+
+java_proto_library(
+    name = "manifest_java_proto",
+    deps = [":manifest_proto"],
+)
+
+cc_proto_library(
+    name = "manifest_cc_proto",
+    deps = [":manifest_proto"],
+)
+
+java_lite_proto_library(
+    name = "manifest_java_proto_lite",
+    deps = [":manifest_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "manifest_cbor_proto",
+    srcs = [":manifest_proto"],
+    deps = [
+        ":manifest_cc_proto",
+    ],
+)
+
+proto_library(
+    name = "validation_result_proto",
+    srcs = ["validation_result.proto"],
+    deps = [":manifest_proto"],
+)
+
+cc_proto_library(
+    name = "validation_result_cc_proto",
+    deps = [":validation_result_proto"],
+)
+
+java_lite_proto_library(
+    name = "validation_result_java_proto_lite",
+    deps = [":validation_result_proto"],
+)
+
+java_proto_library(
+    name = "validation_result_java_proto",
+    deps = [":validation_result_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "validation_result_cbor_proto",
+    srcs = [":validation_result_proto"],
+    deps = [":validation_result_cc_proto"],
+)
+
+proto_library(
+    name = "ingredient_validation_result_proto",
+    srcs = ["ingredient_validation_result.proto"],
+    option_deps = ["@protobuf//:cpp_features_proto"],
+    deps = [
+        ":assertion_proto",
+        ":bmff_based_hash_assertion_proto",
+        ":boxes_hash_assertion_proto",
+        ":collection_data_hash_assertion_proto",
+        ":data_hash_assertion_proto",
+        ":generator_info_proto",
+        ":ingredient_assertion_proto",
+        ":signature_info_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "ingredient_validation_result_cc_proto",
+    deps = [":ingredient_validation_result_proto"],
+)
+
+java_lite_proto_library(
+    name = "ingredient_validation_result_java_proto_lite",
+    deps = [":ingredient_validation_result_proto"],
+)
diff --git a/proto/actions_assertion.proto b/proto/actions_assertion.proto
new file mode 100644
index 0000000..b0968e9
--- /dev/null
+++ b/proto/actions_assertion.proto
@@ -0,0 +1,125 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "proto/generator_info.proto";
+import "proto/hashed_uri.proto";
+
+// An assertion that provides information on edits and other actions taken that
+// affect the asset’s content.
+//
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions
+// for more details.
+message ActionsAssertion {
+  // A list of actions on the asset.
+  repeated ActionItem actions = 1
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+  // A list of templates for the actions.
+  // See template behavior in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_action_templates
+  repeated ActionTemplate templates = 2;
+  repeated GeneratorInfo software_agents = 4
+      [(cbor.tags) = { name: "softwareAgents" }];
+
+  // If true, indicates that all actions performed are included in an actions
+  // assertion. This must be set to a value to be considered conformant.
+  bool all_actions_included = 5 [(cbor.tags) = { name: "allActionsIncluded" }];
+  reserved 3;
+}
+
+message ActionsAssertionV1 {
+  // A list of actions on the asset.
+  repeated ActionItemV1 actions = 1
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+  reserved 2;
+}
+
+message ActionItem {
+  string action = 1 [(cbor.tags) = { is_required: true }];
+  string description = 2;
+  string digital_source_type = 3 [(cbor.tags) = { name: "digitalSourceType" }];
+  GeneratorInfo software_agent = 4 [(cbor.tags) = { name: "softwareAgent" }];
+  int64 software_agent_index = 10
+      [(cbor.tags) = { name: "softwareAgentIndex" }];
+  // List of related actions.
+  repeated ActionItem related_actions = 6 [(cbor.tags) = { name: "related" }];
+  // The reason why this action was performed, required when the action is
+  // `c2pa.redacted`
+  string reason = 7;
+  // Additional parameters of the action.
+  ActionParameters parameters = 9;
+
+  reserved 5, 8;
+}
+
+message ActionItemV1 {
+  string action = 1 [(cbor.tags) = { is_required: true }];
+  string digital_source_type = 2 [(cbor.tags) = { name: "digitalSourceType" }];
+  // Additional parameters of the action.
+  ActionParameters parameters = 3;
+}
+
+message ActionTemplate {
+  string action = 1 [(cbor.tags) = { is_required: true }];
+  string description = 2;
+  string digital_source_type = 3 [(cbor.tags) = { name: "digitalSourceType" }];
+  GeneratorInfo software_agent = 4 [(cbor.tags) = { name: "softwareAgent" }];
+  // Either an internal URI (representing an icon embedded in the manifest
+  // store) or an external URI.
+  InternalOrExternalHashedUri icon = 5;
+  // Additional parameters of the template.
+  repeated ActionParameters.Data parameters = 6
+      [(cbor.tags) = { name: "templateParameters" }];
+}
+
+// Parameters for actions.
+//
+// See `parameters-map-v2` in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_9
+// for more details.
+message ActionParameters {
+  // UUID of the modified (output) resource.
+  string instance_id = 1 [(cbor.tags) = { name: "instanceID" }];
+  // JUMBF URI to the redacted assertion (required for `c2pa.redacted` action).
+  string redacted_uri = 2 [(cbor.tags) = { name: "redacted" }];
+  // List of JUMBF URIs to ingredient assertions.
+  repeated HashedUri ingredients = 3;
+  // For v1 action assertions. When parsing CBOR, the contents of this field
+  // will be moved to the `ingredients` field, so it should always be empty.
+  HashedUri ingredient = 7;
+  // See `parameters-common-map-v2` in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_9
+  // for more details.
+  message Data {
+    string key = 1;
+    bytes value = 2;
+  }
+  // A list of arbitrary string-to-value data.
+  repeated Data data = 4;
+  // BCP-47 code of the source language of a `c2pa.translated` action.
+  string source_language = 5 [(cbor.tags) = { name: "sourceLanguage" }];
+  // BCP-47 code of the target language of a `c2pa.translated` action.
+  string target_language = 6 [(cbor.tags) = { name: "targetLanguage" }];
+  // Was this action performed multiple times?
+  bool multiple_instances = 8 [(cbor.tags) = { name: "multipleInstances" }];
+  // JUMBF URIs to related assertions.
+  repeated HashedUri related_assertions = 9
+      [(cbor.tags) = { name: "relatedAssertions", disallow_empty: true }];
+}
diff --git a/proto/ai_disclosure_assertion.proto b/proto/ai_disclosure_assertion.proto
new file mode 100644
index 0000000..5fa99e9
--- /dev/null
+++ b/proto/ai_disclosure_assertion.proto
@@ -0,0 +1,52 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "google/protobuf/struct.proto";
+
+// Schema for AI Model Disclosure Metadata (c2pa.ai-disclosure)
+// Matches C2PA 2.4 technical specification.
+message AiDisclosureAssertion {
+  // Required. Enumerated string identifying the AI model type.
+  // Examples: "c2pa.types.model.onnx", "c2pa.types.model.flax"
+  string model_type = 1 [(cbor.tags) = { name: "modelType" }];
+
+  // Optional. Human-readable name of the model.
+  string model_name = 2 [(cbor.tags) = { name: "modelName" }];
+
+  // Optional. Unique identifier for the model (e.g. a URI or PURL).
+  string model_identifier = 3 [(cbor.tags) = { name: "modelIdentifier" }];
+
+  // Optional. Structured content profile.
+  ContentProfile content_profile = 4 [(cbor.tags) = { name: "contentProfile" }];
+
+  message ContentProfile {
+    // Optional. Degree of human intervention.
+    // Spec values: "fully_autonomous", "prompt_guided", "human_validated"
+    string human_oversight_level = 1
+        [(cbor.tags) = { name: "humanOversightLevel" }];
+
+    // Optional. Scientific domain of the content (e.g. arXiv taxonomy "cs.AI").
+    repeated string scientific_domain = 2
+        [(cbor.tags) = { name: "scientificDomain" }];
+
+    // Optional. Extensible metadata.
+    google.protobuf.Struct metadata = 3 [(cbor.tags) = { mode: MODE_SKIP }];
+  }
+}
diff --git a/proto/assertion.proto b/proto/assertion.proto
new file mode 100644
index 0000000..737a456
--- /dev/null
+++ b/proto/assertion.proto
@@ -0,0 +1,72 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "proto/actions_assertion.proto";
+import "proto/ai_disclosure_assertion.proto";
+import "proto/bmff_based_hash_assertion.proto";
+import "proto/boxes_hash_assertion.proto";
+import "proto/collection_data_hash_assertion.proto";
+import "proto/data_hash_assertion.proto";
+import "proto/environmental_sustainability_assertion.proto";
+import "proto/ingredient_assertion.proto";
+import "proto/metadata_assertion.proto";
+import "proto/multi_asset_hash_assertion.proto";
+import "proto/redacted_assertion.proto";
+import "proto/repository_receipt_assertion.proto";
+import "proto/soft_binding_assertion.proto";
+import "proto/thumbnail_assertion.proto";
+
+// A standard assertion used by C2PA implementations about when, where and how
+// an asset or an ingredient of a composed asset was originated or transformed.
+//
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_standard_assertions
+// for more details.
+message Assertion {
+  // A label defined by the C2PA specification. The list of publicly known
+  // labels can be found at
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_standard_assertions.
+  string label = 1;
+  reserved 2, 3, 8;
+  oneof assertion {
+    ActionsAssertion actions = 4;
+    ActionsAssertionV1 actions_v1 = 13;
+    // No longer produced by the assertion parser.
+    IngredientAssertionV1 ingredient_v1 = 12 [deprecated = true];
+    // No longer produced by the assertion parser.
+    IngredientAssertionV2 ingredient_v2 = 5 [deprecated = true];
+    IngredientAssertionV3 ingredient_v3 = 14;
+    BmffBasedHashAssertion bmff_based_hash = 6;
+    DataHashAssertion data_hash = 7;
+    BoxesHashAssertion boxes_hash = 9;
+    ThumbnailAssertion claim_thumbnail = 10;
+    ThumbnailAssertion ingredient_thumbnail = 11;
+    MetadataAssertion metadata = 15;
+    RedactedCustomAssertion redacted_custom = 16;
+    RedactedGatheredAssertion redacted_gathered = 17;
+    RedactedDeprecatedAssertion redacted_deprecated = 18;
+    RedactedByGeneratorAssertion redacted_by_generator = 20;
+    MultiAssetHashAssertion multi_asset_hash = 19;
+    CollectionDataHashAssertion collection_data_hash = 21;
+    AiDisclosureAssertion ai_disclosure = 22;
+    EnvironmentalSustainabilityAssertion environmental_sustainability = 23;
+    RepositoryReceiptAssertion repository_receipt = 24;
+    SoftBindingAssertion soft_binding = 25;
+  }
+}
diff --git a/proto/asset_reference_assertion.proto b/proto/asset_reference_assertion.proto
new file mode 100644
index 0000000..2456cc8
--- /dev/null
+++ b/proto/asset_reference_assertion.proto
@@ -0,0 +1,46 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+
+import option "google/protobuf/cpp_features.proto";
+
+// The asset reference assertion (ARA) describes where a copy of the asset may
+// be obtained.
+// Based on `asset-ref-map`:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_reference.
+message AssetReferenceAssertion {
+  repeated AraReferenceBlock references = 1
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+}
+
+message AraReferenceBlock {
+  AraReferenceUri reference = 1 [(cbor.tags) = { is_required: true }];
+
+  // Human readable description of the location.
+  string description = 2 [features.(pb.cpp).string_type = STRING];
+}
+
+message AraReferenceUri {
+  // URI reference a location where a copy of the asset may be obtained from.
+  string uri = 1 [
+    features.(pb.cpp).string_type = STRING,
+    (cbor.tags) = { is_required: true }
+  ];
+}
diff --git a/proto/asset_type.proto b/proto/asset_type.proto
new file mode 100644
index 0000000..e72617a
--- /dev/null
+++ b/proto/asset_type.proto
@@ -0,0 +1,42 @@
+// 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.
+//
+
+edition = "2023";
+
+package credentio;
+
+import "cbor/tags.proto";
+
+option java_multiple_files = true;
+option java_outer_classname = "AssetTypeOuterClass";
+
+// The asset type assertion provides a way to more completely describe an asset
+// specifically additional context on how to parse or otherwise process it when
+// its `dc:format` is insufficient.  It can also be used to describe externally
+// referenced or related assets such as AI/ML models.
+//
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_type
+// for more details.
+message AssetType {
+  // The type of asset. This can either be a custom value or one of the provided
+  // choices in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_11.
+  // If a custom value is used, it must satisfy the regex specified in the C2PA
+  // spec linked above.
+  string type = 1 [(cbor.tags) = { is_required: true }];
+  // Version of the asset.
+  string version = 2;
+}
diff --git a/proto/asset_types_assertion.proto b/proto/asset_types_assertion.proto
new file mode 100644
index 0000000..9cbc753
--- /dev/null
+++ b/proto/asset_types_assertion.proto
@@ -0,0 +1,34 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "proto/asset_type.proto";
+
+// An assertion that provides information on the type of the asset, specifically
+// additional context on how to parse or otherwise process it.
+// Based on `asset-type-map`:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_asset_type.
+message AssetTypesAssertion {
+  string media_type = 3
+      [(cbor.tags) = { is_required: false, name: "dc:format" }];
+  // Types related to the asset.
+  repeated AssetType types = 1 [(cbor.tags) = { is_required: false }];
+
+  reserved 2;
+}
diff --git a/proto/assurance_level.proto b/proto/assurance_level.proto
new file mode 100644
index 0000000..bc910d9
--- /dev/null
+++ b/proto/assurance_level.proto
@@ -0,0 +1,27 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+// C2PA Generator Product Assurance Levels.
+// See
+// https://github.com/c2pa-org/conformance-public/blob/main/docs/current/C2PA%20Generator%20Product%20Security%20Requirements.pdf
+enum AssuranceLevel {
+  ASSURANCE_LEVEL_UNSPECIFIED = 0;
+  ASSURANCE_LEVEL1 = 1;
+  ASSURANCE_LEVEL2 = 2;
+}
diff --git a/proto/bmff_based_hash_assertion.proto b/proto/bmff_based_hash_assertion.proto
new file mode 100644
index 0000000..1c97e9f
--- /dev/null
+++ b/proto/bmff_based_hash_assertion.proto
@@ -0,0 +1,105 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+
+// A BMFF-based hash assertion. See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_bmff_based_hash.
+message BmffBasedHashAssertion {
+  // Excludes certain boxes from the hash.
+  repeated BmffRange exclusions = 1
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+  // A string identifying the cryptographic hash algorithm.
+  string alg = 2;
+  bytes hash = 3;
+  repeated BmffMerkle merkles = 4 [(cbor.tags) = { name: "merkle" }];
+  // A human-readable description of what this hash covers.
+  string name = 5;
+}
+
+message BmffRange {
+  // Location of box(es) of the hash starting from the root node as an xpath
+  // formatted string of version https://www.w3.org/TR/xpath-10/ with highly
+  // constrained syntax.
+  string xpath = 1 [(cbor.tags) = { is_required: true }];
+  // Length of a leafmost box of the hash.
+  int64 length = 2;
+  // The data in the leafmost box.
+  repeated BmffData data = 3;
+  // Only subsets of the data in the leafmost box.
+  repeated BmffSubset subsets = 4 [(cbor.tags) = { name: "subset" }];
+  int64 version = 5;
+  bytes flags = 6;
+  // Whether flags must be an exact match.
+  bool exact = 7;
+}
+
+message BmffData {
+  int64 offset = 1 [(cbor.tags) = { is_required: true }];
+  bytes value = 2 [(cbor.tags) = { is_required: true }];
+}
+
+message BmffSubset {
+  int64 offset = 1 [(cbor.tags) = { is_required: true }];
+  int64 length = 2 [(cbor.tags) = { is_required: true }];
+}
+
+message BmffMerkle {
+  // Unique id that differentiates across files to determine which Merkle
+  // tree should be used to validate a given 'mdat' box.
+  int64 unique_id = 1 [(cbor.tags) = { is_required: true, name: "uniqueId" }];
+  // Local id used to differentiate across multiple 'mdat' boxes within a single
+  // file to determine which Merkle tree should be used to validate that 'mdat'
+  // box.
+  int64 local_id = 2 [(cbor.tags) = { is_required: true, name: "localId" }];
+  // Number of leaf nodes in the Merkle tree.
+  int64 count = 3 [(cbor.tags) = { is_required: true }];
+  // A string identifying the cryptographic hash algorithm
+  string alg = 4;
+  // The hash of the entire initialization segment file for fragmented assets
+  // like MP4.
+  bytes init_hash = 5 [(cbor.tags) = { name: "initHash" }];
+  // Representing a single row of the Merkle tree.
+  repeated bytes hashes = 6
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+  // The following two fields are used to determine the block size of the
+  // Merkle tree and are only used for non-fragmented assets. Only one of them
+  // can be set. FixedBlockSize breaks the input string into fixed-size blocks,
+  // while VariableBlockSizes uses a list of block sizes to determine the block
+  // size of each block.
+  int64 fixed_block_size = 7
+      [(cbor.tags) = { name: "fixedBlockSize", type: TYPE_UINT }];
+  repeated int64 variable_block_sizes = 8
+      [(cbor.tags) = { name: "variableBlockSizes" }];
+}
+
+// Auxiliary data for a Merkle map. See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_box_containing_the_merkle_auxiliary.
+message BmffMerkleMap {
+  // Unique ID used in combination with the local ID to identify the BMFFMerkle
+  // map.
+  int64 unique_id = 1 [(cbor.tags) = { is_required: true, name: "uniqueId" }];
+  int64 local_id = 2 [(cbor.tags) = { is_required: true, name: "localId" }];
+  // The zero-based index of this leaf in the leaf row of the Merkle map.
+  int64 location = 3 [(cbor.tags) = { is_required: true }];
+  // A series of hashes that are sequentially hashed against the leaf data hash
+  // via merkle proof to generate the final hash to be compared against the
+  // corresponding hash in the Merkle map.
+  repeated bytes hashes = 4 [(cbor.tags) = { disallow_empty: true }];
+}
diff --git a/proto/boxes_hash_assertion.proto b/proto/boxes_hash_assertion.proto
new file mode 100644
index 0000000..de08d75
--- /dev/null
+++ b/proto/boxes_hash_assertion.proto
@@ -0,0 +1,51 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+
+// Based on `box-hash-map`:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_boxes_hash.
+message BoxesHashAssertion {
+  repeated BoxHash boxes = 1
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+  string alg = 2;
+}
+
+message BoxExclusion {
+  int64 start = 1 [(cbor.tags) = { is_required: true, type: TYPE_UINT }];
+  int64 length = 2 [(cbor.tags) = { is_required: true, type: TYPE_UINT }];
+  int32 box_index = 3 [(cbor.tags) = { name: "boxIndex" }];
+}
+
+message BoxHash {
+  repeated string names = 1
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+  string alg = 2;
+  bytes hash = 3 [(cbor.tags) = { is_required: true }];
+  // Is the hash value excluded for this set of boxes.
+  bool excluded = 6;
+  // An array of hash exclusion ranges. Ranges have monotonically increasing
+  // `start` values, and no two ranges may overlap.
+  repeated BoxExclusion exclusions = 7;
+
+  // For internal use in the manifest generator -- do not set or read.
+  bytes pad = 4 [(cbor.tags) = { mode: MODE_WRITE_ONLY }];
+  // For internal use in the manifest generator -- do not set or read.
+  bytes pad2 = 5 [(cbor.tags) = { mode: MODE_WRITE_ONLY }];
+}
diff --git a/proto/collection_data_hash_assertion.proto b/proto/collection_data_hash_assertion.proto
new file mode 100644
index 0000000..47531a9
--- /dev/null
+++ b/proto/collection_data_hash_assertion.proto
@@ -0,0 +1,56 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "proto/asset_type.proto";
+
+// `CollectionDataHashAssertion` contains an array of URIs and their associated
+// hashes. Based on `collection-data-hash-map`:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_collection_data_hash.
+message CollectionDataHashAssertion {
+  repeated UriHashedData uris = 1 [(cbor.tags) = { is_required: true }];
+
+  // A string identifying the cryptographic hash algorithm used to compute the
+  // hash on each entry of the `uris` array, taken from the C2PA hash algorithm
+  // identifier list.
+  string alg = 2 [(cbor.tags) = { is_required: true }];
+
+  // The hash of the ZIP central directory in ZIP-based assets.
+  bytes zip_central_directory_hash = 3;
+}
+
+// `UriHashedData` is used to store a reference to a URI and its hash. Based on
+// `uri-hashed-data-map`:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_collection_data_hash.
+message UriHashedData {
+  // Relative URI reference.
+  string uri = 1 [(cbor.tags) = { is_required: true }];
+
+  // Byte string containing the hash value.
+  bytes hash = 2 [(cbor.tags) = { is_required: true }];
+
+  // Number of bytes of data.
+  int64 size = 3;
+
+  // IANA media type of the data.
+  string type = 4 [(cbor.tags) = { name: "dc:format" }];
+
+  // Additional information about the data's type.
+  repeated AssetType data_types = 5;
+}
diff --git a/proto/cose_verification_result.proto b/proto/cose_verification_result.proto
new file mode 100644
index 0000000..2180998
--- /dev/null
+++ b/proto/cose_verification_result.proto
@@ -0,0 +1,33 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "proto/signature_info.proto";
+import "proto/validation_status.proto";
+
+option java_outer_classname = "CoseVerificationResultOuterClass";
+
+// Result of a COSE signature verification.
+message CoseVerificationResult {
+  // Provided when the entire verification completes successfully.
+  credentio.SignatureInfo signature_info = 1;
+
+  // Set of validation statuses encountered during verification.
+  // Validation should be considered failed if any failure statuses are present.
+  credentio.ValidationStatusSet validation_status = 2;
+}
diff --git a/proto/data_hash_assertion.proto b/proto/data_hash_assertion.proto
new file mode 100644
index 0000000..c820b03
--- /dev/null
+++ b/proto/data_hash_assertion.proto
@@ -0,0 +1,46 @@
+// 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.
+//
+
+edition = "2023";
+
+package credentio;
+
+import "cbor/tags.proto";
+
+// The data structure used to store the cryptographic hash of some or all of the
+// asset's data and additional information required to compute the hash.
+// Based on `data-hash-map`:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_data_hash.
+message DataHashAssertion {
+  repeated DataRange exclusions = 1;
+  // A string identifying the cryptographic hash algorithm used to compute the
+  // hash in this assertion.
+  string alg = 2;
+  bytes hash = 3 [(cbor.tags) = { is_required: true }];
+  // A human-readable description of what this hash covers.
+  string name = 6;
+
+  // For internal use in the manifest generator -- do not set or read.
+  bytes pad = 4 [(cbor.tags) = { is_required: true, mode: MODE_WRITE_ONLY }];
+  // For internal use in the manifest generator -- do not set or read.
+  bytes pad2 = 5 [(cbor.tags) = { mode: MODE_WRITE_ONLY }];
+}
+
+message DataRange {
+  // Starting byte of the range.
+  int64 start = 1 [(cbor.tags) = { is_required: true }];
+  // Number of bytes of data.
+  int64 length = 2 [(cbor.tags) = { is_required: true }];
+}
diff --git a/proto/environmental_sustainability_assertion.proto b/proto/environmental_sustainability_assertion.proto
new file mode 100644
index 0000000..000a347
--- /dev/null
+++ b/proto/environmental_sustainability_assertion.proto
@@ -0,0 +1,41 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+
+// Schema for Environmental Sustainability Metadata
+// (c2pa.environmental-sustainability) Matches C2PA 2.4 technical specification.
+message EnvironmentalSustainabilityAssertion {
+  message Metric {
+    // Required. The value of the metric.
+    double value = 1 [(cbor.tags) = { name: "value" }];
+
+    // Optional. The method used to measure the metric (reverse-DNS format).
+    string measurement_method = 2 [(cbor.tags) = { name: "measurementMethod" }];
+  }
+
+  // Optional. Energy consumption in kilowatt-hours (kWh).
+  Metric energy_kwh = 1 [(cbor.tags) = { name: "energy_kwh" }];
+
+  // Optional. Greenhouse gas emissions in kilograms of CO2 equivalent (kgCO2e).
+  Metric carbon_kgco2e = 2 [(cbor.tags) = { name: "carbon_kgco2e" }];
+
+  // Optional. Water usage in litres.
+  Metric water_litres = 3 [(cbor.tags) = { name: "water_litres" }];
+}
diff --git a/proto/generator_info.proto b/proto/generator_info.proto
new file mode 100644
index 0000000..c91adf6
--- /dev/null
+++ b/proto/generator_info.proto
@@ -0,0 +1,38 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "proto/hashed_uri.proto";
+
+// Information about the claim or action generator.
+// See `generator-info-map` in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_syntax
+// for more details.
+message GeneratorInfo {
+  // Name of the claim generator.
+  string name = 1 [(cbor.tags) = { is_required: true }];
+  // Version of the claim generator.
+  string version = 2;
+  // URI to the icon of the claim generator.
+  InternalOrExternalHashedUri icon = 3;
+  // The operating system the claim generator is running on
+  string operating_system = 4;
+  // The spec version of the claim generator.
+  string spec_version = 5 [(cbor.tags) = { name: "specVersion" }];
+}
diff --git a/proto/hashed_uri.proto b/proto/hashed_uri.proto
new file mode 100644
index 0000000..6e23503
--- /dev/null
+++ b/proto/hashed_uri.proto
@@ -0,0 +1,50 @@
+// 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.
+//
+
+edition = "2023";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "proto/asset_type.proto";
+
+option java_multiple_files = true;
+option java_outer_classname = "HashedUriOuterClass";
+
+// An internal (within the manifest store) hashed URI reference.
+message HashedUri {
+  // The URI reference.
+  string url = 1 [(cbor.tags) = { is_required: true }];
+  // The algorithm used to compute the hash.
+  string algorithm = 2 [(cbor.tags) = { name: "alg" }];
+  // The hash value.
+  bytes hash = 3 [(cbor.tags) = { is_required: true }];
+}
+
+// An internal (within the manifest store) or external hashed URI reference.
+message InternalOrExternalHashedUri {
+  // The URI reference.
+  string url = 1 [(cbor.tags) = { is_required: true }];
+  // The algorithm used to compute the hash.
+  string algorithm = 2 [(cbor.tags) = { name: "alg" }];
+  // The hash value.
+  bytes hash = 3 [(cbor.tags) = { is_required: true }];
+  // The media type of the data (for external URIs).
+  string media_type = 4 [(cbor.tags) = { name: "dc:format" }];
+  // The size of the data (for external URIs).
+  int64 size = 5;
+  // Additional information about the data's type (for external URIs).
+  repeated AssetType data_types = 6;
+}
diff --git a/proto/ingredient_assertion.proto b/proto/ingredient_assertion.proto
new file mode 100644
index 0000000..7e8c403
--- /dev/null
+++ b/proto/ingredient_assertion.proto
@@ -0,0 +1,134 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "proto/asset_type.proto";
+import "proto/hashed_uri.proto";
+import "proto/validation_status.proto";
+
+// Represents an assertion about an ingredient used in the asset.
+// Based on `ingredient-map-v2` in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ingredient_schema.
+message IngredientAssertionV2 {
+  // The name of the ingredient.
+  string title = 1 [(cbor.tags) = { is_required: true, name: "dc:title" }];
+  // The media type of the ingredient.
+  string media_type = 2
+      [(cbor.tags) = { is_required: true, name: "dc:format" }];
+  // The relationship of this ingredient to the asset it is an ingredient of.
+  string relationship = 3;
+  // The value of the ingredient's `xmpMM:DocumentID`.
+  string document_id = 4 [(cbor.tags) = { name: "documentID" }];
+  // Unique identifier, such as the value of the ingredient's
+  // `xmpMM:InstanceID`.
+  string instance_id = 5 [(cbor.tags) = { name: "instanceID" }];
+  // URI reference to a data box or external data containing the ingredient
+  // asset itself.
+  InternalOrExternalHashedUri data = 6;
+  // Additional information about the data's type.
+  repeated AssetType data_types = 7;
+  // URI reference to the C2PA Manifest of the ingredient.
+  HashedUri c2pa_manifest = 8;
+  // URI reference to a thumbnail in a data box.
+  HashedUri thumbnail = 9;
+  // Validation status of the ingredient (provided by the claim generator).
+  repeated ValidationStatus validation_status = 14
+      [(cbor.tags) = { name: "validationStatus" }];
+  // Additional description of the ingredient.
+  string description = 11;
+  // URI to an informational page about the ingredient or its data.
+  string informational_uri = 12 [(cbor.tags) = { name: "informational_URI" }];
+  reserved 13;
+
+  reserved 10;
+}
+
+// Represents an assertion about an ingredient used in the asset.
+// Based on `ingredient-map` in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ingredient_schema.
+message IngredientAssertionV1 {
+  // The name of the ingredient.
+  string title = 1 [(cbor.tags) = { is_required: true, name: "dc:title" }];
+  // The media type of the ingredient.
+  string media_type = 2
+      [(cbor.tags) = { is_required: true, name: "dc:format" }];
+  // The value of the ingredient's `xmpMM:DocumentID`.
+  string document_id = 3 [(cbor.tags) = { name: "documentID" }];
+  // Unique identifier, such as the value of the ingredient's
+  // `xmpMM:InstanceID`.
+  string instance_id = 4
+      [(cbor.tags) = { is_required: true, name: "instanceID" }];
+  // The relationship of this ingredient to the asset it is an ingredient of.
+  string relationship = 5;
+  // URI reference to the C2PA Manifest of the ingredient.
+  HashedUri c2pa_manifest = 6;
+  // URI reference to a thumbnail in a data box.
+  HashedUri thumbnail = 7;
+  // Validation status of the ingredient (provided by the claim generator).
+  repeated ValidationStatus validation_status = 10
+      [(cbor.tags) = { name: "validationStatus" }];
+  reserved 9;
+
+  reserved 8;
+}
+
+// Represents an assertion about an ingredient used in the asset.
+// Based on `ingredient-map-v3` in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#ingredient_schema.
+message IngredientAssertionV3 {
+  string title = 1 [(cbor.tags) = { name: "dc:title" }];
+  string media_type = 2 [(cbor.tags) = { name: "dc:format" }];
+  string relationship = 3;
+  IngredientValidationResults validation_results = 4
+      [(cbor.tags) = { name: "validationResults" }];
+  string instance_id = 5 [(cbor.tags) = { name: "instanceID" }];
+  InternalOrExternalHashedUri data = 6;
+  repeated AssetType data_types = 7 [(cbor.tags) = { name: "dataTypes" }];
+  HashedUri active_manifest = 8 [(cbor.tags) = { name: "activeManifest" }];
+  HashedUri claim_signature = 9 [(cbor.tags) = { name: "claimSignature" }];
+  HashedUri thumbnail = 10;
+  string description = 11;
+  string informational_uri = 12 [(cbor.tags) = { name: "informationalURI" }];
+  string digital_source_type = 14 [(cbor.tags) = { name: "digitalSourceType" }];
+  reserved 13;
+}
+
+// Changes/deltas between the current and previous validation results for each
+// ingredient's manifest.
+// See `ingredient-delta-validation-result-map` at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_returning_validation_results.
+message IngredientDeltaValidationResult {
+  string ingredient_assertion_uri = 1
+      [(cbor.tags) = { name: "ingredientAssertionURI", is_required: true }];
+  ValidationStatusSet validation_deltas = 2
+      [(cbor.tags) = { name: "validationDeltas", is_required: true }];
+}
+
+// Results from the claim generator performing full validation.
+// See `validation-results-map` at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_returning_validation_results.
+message IngredientValidationResults {
+  ValidationStatusSet active_manifest = 1
+      [(cbor.tags) = { name: "activeManifest" }];
+  repeated IngredientDeltaValidationResult ingredient_deltas = 2
+      [(cbor.tags) = { name: "ingredientDeltas" }];
+  string spec_version = 6 [(cbor.tags) = { name: "specVersion" }];
+  string trust_list_uri = 7 [(cbor.tags) = { name: "trustListUri" }];
+  reserved 3, 4, 5;
+}
diff --git a/proto/ingredient_validation_result.proto b/proto/ingredient_validation_result.proto
new file mode 100644
index 0000000..a9ca2e6
--- /dev/null
+++ b/proto/ingredient_validation_result.proto
@@ -0,0 +1,142 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "proto/assertion.proto";
+import "proto/bmff_based_hash_assertion.proto";
+import "proto/boxes_hash_assertion.proto";
+import "proto/collection_data_hash_assertion.proto";
+import "proto/data_hash_assertion.proto";
+import "proto/generator_info.proto";
+import "proto/ingredient_assertion.proto";
+import "proto/signature_info.proto";
+
+import option "google/protobuf/cpp_features.proto";
+
+option features.(pb.cpp).string_type = STRING;
+option java_outer_classname = "IngredientValidationResult";
+option objc_class_prefix = "C2PA";
+
+// Result of a successful or failed validation of an ingredient asset.
+message IngredientValidationResultProto {
+  // Optional.
+  // If omitted, the ingredient will be treated as having unknown provenance and
+  // ingredient_validation_results will be ignored.
+  bytes manifest_store_bytes = 1;
+
+  // The IANA media type of the asset (e.g. "image/jpeg"), if known.
+  string media_type = 6;
+
+  // Optional. Full validation results for the ingredient.
+  // SHOULD BE SET IF `manifest_store_bytes` IS SET.
+  IngredientValidationResults ingredient_validation_results = 2;
+
+  // Metadata about the ingredient asset for logging/monitoring purposes.
+  IngredientValidationLoggingMetadata logging_metadata = 3;
+
+  // Optional. Metadata about the ingredient asset for legacy manifests.
+  LegacyIngredientMetadata legacy_ingredient_metadata = 5;
+
+  // Contains the URI and redaction reason for a redacted assertion.
+  message RedactedAssertion {
+    string uri = 1;
+    string reason = 2;
+  }
+
+  // DO NOT MANUALLY SET. If set then `manifest_store_bytes` contains the
+  // redacted ingredient manifest store. Should only be set by the
+  // ManifestStoreRedactor.
+  repeated RedactedAssertion redactions = 7;
+
+  // Optional. The summarized digital source type of the ingredient asset, if
+  // known. Used in cases of unknown provenance or if the ingredient manifest
+  // store was redacted.
+  string digital_source_type = 8;
+
+  reserved 4;
+}
+
+// Result of a partial validation of an ingredient manifest store, where the
+// content binding has not yet been validated.
+message PartialIngredientValidationResultProto {
+  // Required. Validation results for the ingredient not including hard binding
+  // validation results.
+  IngredientValidationResults ingredient_validation_results = 1;
+
+  // Metadata about the ingredient asset for logging/monitoring purposes.
+  IngredientValidationLoggingMetadata logging_metadata = 3;
+
+  // Contains the relevant hard binding assertion binding the c2pa manifest
+  // store to the asset.
+  message HardBindingAssertion {
+    // Required.
+    string hard_binding_uri = 1;
+    // JUMBF URI of the relevant multi-asset hash assertion, if one was found.
+    // This must be located in the same manifest as the `hard_binding_uri`.
+    string multi_asset_hash_uri = 7;
+
+    // Required. The relevant hard binding assertion.
+    oneof hard_binding_assertion {
+      BmffBasedHashAssertion bmff_based_hash = 2;
+      DataHashAssertion data_hash = 3;
+      BoxesHashAssertion boxes_hash = 4;
+      CollectionDataHashAssertion collection_data_hash = 8;
+    }
+
+    // All hash assertions for the asset, including the primary hard binding
+    // assertion as well as (if present) the multi-asset hash assertion and the
+    // part hash assertions it references. Order is not significant.
+    repeated Assertion assertions = 6;
+
+    // Optional. If the active manifest is an update manifest then the
+    // `hard_binding_uri` will point into a different manifest from the
+    // `active_manifest_uri`.
+    string active_manifest_uri = 5;
+  }
+
+  // Optional. The relevant hard binding assertion, if one was found.
+  HardBindingAssertion hard_binding_assertion = 2;
+
+  // Optional. Metadata about the ingredient asset for legacy manifests.
+  LegacyIngredientMetadata legacy_ingredient_metadata = 5;
+
+  // Optional. The summarized digital source type of the ingredient asset, if
+  // known. Currently only set for assets with AI generation usage.
+  string digital_source_type = 6;
+}
+
+message LegacyIngredientMetadata {
+  // True if the active manifest is a legacy manifest.
+  bool is_legacy_ingredient = 1;
+  // Includes labels of legacy manifests unsupported by this validator.
+  repeated string unsupported_manifests = 2;
+  // Includes JUMBF URIs of ingredient assertions that point to legacy manifests
+  // unsupported by this validator. Assertions may be in any of the ingredient
+  // manifests or the active manifest.
+  repeated string unsupported_ingredient_assertion_uris = 3;
+}
+
+// Metadata about an ingredient asset for logging/monitoring purposes.
+message IngredientValidationLoggingMetadata {
+  message ManifestInfo {
+    GeneratorInfo claim_generator = 1;
+    SignatureInfo signature = 2;
+  }
+  // Information about the active manifest.
+  ManifestInfo active_manifest = 1;
+}
diff --git a/proto/manifest.proto b/proto/manifest.proto
new file mode 100644
index 0000000..d3186d3
--- /dev/null
+++ b/proto/manifest.proto
@@ -0,0 +1,100 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "proto/assertion.proto";
+import "proto/generator_info.proto";
+import "proto/hashed_uri.proto";
+import "proto/signature_info.proto";
+import "proto/validation_status.proto";
+
+import option "google/protobuf/cpp_features.proto";
+
+option java_outer_classname = "ManifestOuterClass";
+option features.(pb.cpp).string_type = STRING;
+
+// Represents a verifiable unit containing a set of assertions and a claim
+// about an asset together with a claim signature.
+message Manifest {
+  // Results of validating this manifest.
+  ValidationStatusSet validation = 15;
+
+  // Whether the manifest is an update manifest.
+  bool is_update_manifest = 5;
+
+  // Label of the manifest, unique within the containing manifest store.
+  string label = 1;
+
+  // Container holding all the assertion references about the asset.
+  Claim claim = 2;
+
+  // A set of assertions associated with the asset, which are referenced by
+  // the claim in this manifest. These assertions constitute the "assertion
+  // store" described in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_assertion_store.
+  repeated Assertion assertions = 3;
+
+  reserved 4;
+}
+
+// Contains all the assertion and redacted assertion references about an asset
+// at a given time.
+message Claim {
+  // Uniquely identifies a specific version of an asset
+  string instance_id = 13 [(cbor.tags) = { name: "instanceID" }];
+
+  // Information about the claim generator.
+  GeneratorInfo claim_generator_info = 1;
+
+  // A set of references to the assertions about when, where, and how the asset
+  // was originated or transformed.
+  //
+  // The reference is specified as a JUMBF URI reference. See
+  // https://www.iso.org/standard/84635.html for more details about JUMBF
+  // standard.
+  repeated HashedUri created_assertions = 7;
+  repeated HashedUri gathered_assertions = 8;
+
+  // A set of references to the assertions that were subject to redaction.
+  //
+  // Redaction is a process where an assertion of an asset-embedded manifest is
+  // removed from that asset's manifest when this asset is used as an
+  // ingredient. See
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_redaction_of_assertions
+  // for more details about the redaction process.
+  //
+  // The reference is specified as a JUMBF URI reference. See
+  // https://www.iso.org/standard/84635.html for more details about JUMBF
+  // standard.
+  repeated string redacted_assertions = 4;
+
+  // Information about the claim signature.
+  SignatureInfo signature_info = 5 [(cbor.tags) = { mode: MODE_SKIP }];
+
+  // Only used by the generator; the validator shouldn't populate this field.
+  string signature = 11;
+
+  // Default hash algorithm.
+  string default_algorithm = 10 [(cbor.tags) = { name: "alg" }];
+
+  // Label of the claim box.
+  string label = 12;
+
+  reserved 2, 3, 6, 9;
+}
diff --git a/proto/metadata_assertion.proto b/proto/metadata_assertion.proto
new file mode 100644
index 0000000..ea6e617
--- /dev/null
+++ b/proto/metadata_assertion.proto
@@ -0,0 +1,28 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "google/protobuf/struct.proto";
+
+// C2PA metadata.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_metadata.
+message MetadataAssertion {
+  google.protobuf.Struct value = 1 [(cbor.tags) = { mode: MODE_SKIP }];
+}
diff --git a/proto/multi_asset_hash_assertion.proto b/proto/multi_asset_hash_assertion.proto
new file mode 100644
index 0000000..b18b22d
--- /dev/null
+++ b/proto/multi_asset_hash_assertion.proto
@@ -0,0 +1,55 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "proto/hashed_uri.proto";
+
+// Based on `multi-asset-hash-map`:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_general_box_hash.
+message MultiAssetHashAssertion {
+  repeated MultiAssetPart parts = 1
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+
+  // For internal use in the manifest generator -- do not set or read.
+  bytes pad = 2 [(cbor.tags) = { is_required: true }];
+  // For internal use in the manifest generator -- do not set or read.
+  bytes pad2 = 3;
+}
+
+// Based on `locator-map`:
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_schema_and_example_6.
+// Either the `byte_offset` and `length` of a byte range or the `bmff_box` of a
+// BMFF box.
+message MultiAssetPartLocation {
+  // These two fields are used to specify a window of bytes within a file.
+  int64 byte_offset = 1 [(cbor.tags) = { name: "byteOffset", type: TYPE_UINT }];
+  int64 length = 2 [(cbor.tags) = { type: TYPE_UINT }];
+
+  // This field is used to specify a BMFF box.
+  string bmff_box = 3 [(cbor.tags) = { name: "bmffBox" }];
+}
+
+message MultiAssetPart {
+  // The location of the hash part.
+  MultiAssetPartLocation location = 1;
+  // The hash of the part.
+  HashedUri hash_assertion = 2 [(cbor.tags) = { name: "hashAssertion" }];
+  // Whether the hash is optional.
+  bool is_optional = 3 [(cbor.tags) = { name: "optional" }];
+}
diff --git a/proto/redacted_assertion.proto b/proto/redacted_assertion.proto
new file mode 100644
index 0000000..cd82a92
--- /dev/null
+++ b/proto/redacted_assertion.proto
@@ -0,0 +1,45 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+// A custom assertion that has been redacted by the validator.
+// The original assertion label will be replaced with one starting with
+// com.google.redacted_custom_assertion.
+// This redaction is a separate process from redaction by a claim generator.
+message RedactedCustomAssertion {}
+
+// A gathered assertion that has been redacted by the validator.
+// The original assertion label will be replaced with one starting with
+// com.google.redacted_gathered_assertion.
+// This redaction is a separate process from redaction by a claim generator.
+message RedactedGatheredAssertion {
+  // The original label of the assertion.
+  string original_label = 1;
+}
+
+// A deprecated standard assertion that has been redacted by the validator.
+// The original assertion label will be replaced with one starting with
+// com.google.redacted_deprecated_assertion.
+// This redaction is a separate process from redaction by a claim generator.
+message RedactedDeprecatedAssertion {
+  // The original label of the assertion.
+  string original_label = 1;
+}
+
+// An assertion that was redacted by a subsequent claim generator.
+message RedactedByGeneratorAssertion {}
diff --git a/proto/repository_receipt_assertion.proto b/proto/repository_receipt_assertion.proto
new file mode 100644
index 0000000..c670300
--- /dev/null
+++ b/proto/repository_receipt_assertion.proto
@@ -0,0 +1,50 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "google/protobuf/struct.proto";
+
+// Schema for Repository Receipt Metadata (c2pa.repository-receipt)
+// Matches C2PA 2.4 technical specification.
+message RepositoryReceiptAssertion {
+  message Repository {
+    // Required. The URI of the repository.
+    string uri = 1;
+
+    // Required. The unique ID of the ingested manifest.
+    string manifest_id = 2;
+  }
+
+  message Anchor {
+    // Required. A reference URI for the receipt.
+    string uri = 1;
+
+    // Optional. Parameters used when referencing the ingestion proof.
+    google.protobuf.Struct parameters = 2 [(cbor.tags) = { mode: MODE_SKIP }];
+
+    // Required. Cryptographic proof confirming the ingestion.
+    google.protobuf.Struct proof = 3 [(cbor.tags) = { mode: MODE_SKIP }];
+  }
+
+  // Required. Details of the repository.
+  Repository repository = 1;
+
+  // Required. Details of the anchor/proof.
+  Anchor anchor = 2;
+}
diff --git a/proto/signature_info.proto b/proto/signature_info.proto
new file mode 100644
index 0000000..ffdcfe7
--- /dev/null
+++ b/proto/signature_info.proto
@@ -0,0 +1,57 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "google/protobuf/timestamp.proto";
+import "proto/assurance_level.proto";
+
+// Information about a C2PA claim signature.
+message SignatureInfo {
+  // The serial number of the certificate.
+  string cert_serial_number = 1;
+
+  // Signer of the claim, a DN in RFC 2253 format (e.g., "CN=C2PA Signer,OU=FOR
+  // TESTING_ONLY,O=C2PA Test Signing Cert,L=Somewhere,ST=CA,C=US").
+  //
+  // The field name is `issuer` (as in the open-source SDK) because this is the
+  // entity that issued the content credentials.
+  string issuer = 2;
+
+  // Issuer of the claim signer's certificate, a DN in RFC 2253 format (e.g.,
+  // "CN=Intermediate CA,OU=FOR TESTING_ONLY,O=C2PA Test Intermediate Root CA,
+  // L=Somewhere,ST=CA,C=US"). The identities of any other CAs in the trust
+  // chain are not included.
+  string certificate_issuer = 4;
+
+  // A time-stamp applied to the signature, produced by a trusted Time-Stamp
+  // Authority.
+  //
+  // NOTE: Assuming claim validation succeeds, the asset itself can be assumed
+  // to have been created at or before this time. Any display of this time to
+  // users should take care not to give the impression that the asset was
+  // created *at* that time -- it could have been created years earlier.
+  google.protobuf.Timestamp timestamping_time = 3;
+
+  // Assurance levels for the signing cert, matching the C2PA Generator Product
+  // Security requirements.
+  AssuranceLevel assurance_level = 5;
+
+  // The string value of the UUID for the conforming products list record
+  // (e.g., "0197aa34-665b-7811-9bbc-317cfd988d0a").
+  string conforming_product_id = 6;
+}
diff --git a/proto/soft_binding_assertion.proto b/proto/soft_binding_assertion.proto
new file mode 100644
index 0000000..7b614d8
--- /dev/null
+++ b/proto/soft_binding_assertion.proto
@@ -0,0 +1,93 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+import "google/protobuf/struct.proto";
+
+message SoftBindingMetadataMap {
+  // Additional description of the implementation or author of the binding or
+  // the algorithm
+  string description = 1;
+  // Contact information for the implementation or author of the binding or the
+  // algorithm
+  string contact = 2;
+  // A web page containing more details about the implementation or author of
+  // the binding or the algorithm.
+  string informational_url = 3 [(cbor.tags) = { name: "informationalUrl" }];
+
+  // Additional metadata of the soft binding.
+  // * tstr => any
+}
+
+message SoftBindingTimespanMap {
+  // Start of the time range (as milliseconds from media start)
+  int64 start = 1 [(cbor.tags) = { is_required: true, type: TYPE_UINT }];
+  // End of the time range (as milliseconds from media start)
+  int64 end = 2 [(cbor.tags) = { is_required: true, type: TYPE_UINT }];
+}
+
+message SoftBindingScopeMap {
+  // CBOR byte string describing, in algorithm specific format,  the part of the
+  // digital content over which the soft binding value has been computed"
+  bytes extent = 1;
+  // The time range over which the soft binding value has been computed.
+  SoftBindingTimespanMap timespan = 2;
+  // The region of interest over which the soft binding value has been computed.
+  // RegionOfInterestMap region = 3;
+}
+
+message SoftBindingBlockMap {
+  SoftBindingScopeMap scope = 1 [(cbor.tags) = { is_required: true }];
+  // CBOR byte string describing, in algorithm specific format, the value of the
+  // soft binding computed over this block of digital content"
+  bytes value = 2 [(cbor.tags) = { is_required: true }];
+}
+
+// Schema for Soft Binding Assertion (c2pa.soft-binding)
+// Matches C2PA 2.4 technical specification.
+message SoftBindingAssertion {
+  // A string identifying the soft binding algorithm and version of that
+  // algorithm used to compute the value, taken from the C2PA soft binding
+  // algorithm list. If this field is absent, the algorithm is taken from the
+  // `alg_soft` value of the enclosing structure. If both are present, the field
+  // in this structure is used.  If no value is present in any of these places,
+  // this structure is invalid; there is no default.
+  string algorithm = 1 [(cbor.tags) = { is_required: true, name: "alg" }];
+  repeated SoftBindingBlockMap blocks = 2
+      [(cbor.tags) = { is_required: true, disallow_empty: true }];
+  // For internal use in the manifest generator -- do not set or read.
+  bytes pad = 4 [(cbor.tags) = { mode: MODE_WRITE_ONLY }];
+  // For internal use in the manifest generator -- do not set or read.
+  bytes pad2 = 5 [(cbor.tags) = { mode: MODE_WRITE_ONLY }];
+
+  // A human-readable description of what this hash covers
+  string name = 6;
+
+  // CBOR byte string describing parameters of the soft binding algorithm.
+  bytes alg_params = 7 [(cbor.tags) = { name: "alg-params" }];
+
+  // Additional metadata of the soft binding. Useful for binding-specific
+  // information.
+  SoftBindingMetadataMap binding_metadata = 8
+      [(cbor.tags) = { name: "bindingMetadata" }];
+
+  // Additional information about the assertion
+  // AssertionMetadataMap assertion_metadata = 9
+  //     [(cbor.tags) = { name: "metadata" }];
+}
diff --git a/proto/thumbnail_assertion.proto b/proto/thumbnail_assertion.proto
new file mode 100644
index 0000000..c57c785
--- /dev/null
+++ b/proto/thumbnail_assertion.proto
@@ -0,0 +1,30 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#thumbnail_assertion
+// for more details.
+message ThumbnailAssertion {
+  // The media type of the thumbnail file. Required.
+  string media_type = 1;
+  // The bits of the thumbnail file. Required.
+  bytes data = 2;
+  // An optional name of the thumbnail file.
+  string file_name = 3;
+}
diff --git a/proto/validation_result.proto b/proto/validation_result.proto
new file mode 100644
index 0000000..01f367f
--- /dev/null
+++ b/proto/validation_result.proto
@@ -0,0 +1,65 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "proto/manifest.proto";
+
+option java_outer_classname = "ValidationResult";
+
+// Result of a successful or failed validation of content credentials.
+message ValidationResultProto {
+  Manifest active_manifest = 3;
+
+  // Validation results for all ingredient manifests.
+  repeated Manifest ingredient_manifests = 4;
+
+  // The IANA media type of the asset (e.g. "image/jpeg"), if known.
+  string media_type = 6;
+
+  // The C2PA spec version of the validator used to validate the manifest.
+  string spec_version = 7;
+
+  // The trust list URI used by the validator.
+  string trust_list_uri = 8;
+
+  reserved 1, 2, 5;
+}
+
+// Result of a partial validation of content credentials, where the content
+// binding has not yet been validated.
+message PartialValidationResultProto {
+  Manifest active_manifest = 1;
+
+  // Validation results for all ingredient manifests.
+  repeated Manifest ingredient_manifests = 2;
+
+  // JUMBF URI of the relevant hard binding assertion, if one was found.
+  // This will be in the active manifest if it is a standard manifest, and
+  // in an ingredient manifest otherwise.
+  string hard_binding_uri = 3;
+
+  // JUMBF URI of the relevant multi-asset hash assertion, if one was found.
+  // This must be located in the same manifest as the `hard_binding_uri`.
+  string multi_asset_hash_uri = 4;
+
+  // The C2PA spec version of the validator used to validate the manifest.
+  string spec_version = 5;
+
+  // The trust list URI used by the validator.
+  string trust_list_uri = 6;
+}
diff --git a/proto/validation_status.proto b/proto/validation_status.proto
new file mode 100644
index 0000000..b41f12f
--- /dev/null
+++ b/proto/validation_status.proto
@@ -0,0 +1,51 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio;
+
+import "cbor/tags.proto";
+
+// Result of a single validation check.
+// See `status-map` at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_returning_validation_results
+// for more details.
+message ValidationStatus {
+  // A label-formatted string that describes the status.
+  // A list of all possible status codes can be found at
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_standard_status_codes.
+  string code = 1 [(cbor.tags) = { is_required: true }];
+  // JPEG Universal Metadata Box Format (JUMBF) URI reference to the relevant
+  // element of the manifest store, including `self#jumbf=/c2pa/` prefix.
+  // See https://www.iso.org/standard/84635.html for more details about JUMBF
+  // standard.
+  string url = 2;
+  //  A human readable string explaining the status.
+  string explanation = 3;
+}
+
+// Results of a manifest validation.
+// Validation is successful if `failures` is empty.
+// See `status-codes-map` at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_returning_validation_results.
+message ValidationStatusSet {
+  repeated ValidationStatus failures = 1
+      [(cbor.tags) = { name: "failure", is_required: true }];
+  repeated ValidationStatus successes = 2
+      [(cbor.tags) = { name: "success", is_required: true }];
+  repeated ValidationStatus informationals = 3
+      [(cbor.tags) = { name: "informational", is_required: true }];
+}
diff --git a/testing/BUILD b/testing/BUILD
new file mode 100644
index 0000000..94b3444
--- /dev/null
+++ b/testing/BUILD
@@ -0,0 +1,303 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(
+    default_testonly = True,
+    default_visibility = ["//visibility:public"],
+)
+
+cc_library(
+    name = "test_string_utils",
+    hdrs = ["test_string_utils.h"],
+    deps = [
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "mock_cose_verifier",
+    hdrs = ["mock_cose_verifier.h"],
+    deps = [
+        "//cose:verifier",
+        "//proto:cose_verification_result_cc_proto",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest",
+    ],
+)
+
+cc_library(
+    name = "mock_assertion_validator",
+    hdrs = ["mock_assertion_validator.h"],
+    deps = [
+        "//assertion:validator",
+        "//jumbf:uri",
+        "//validator:tracker",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@googletest//:gtest",
+    ],
+)
+
+cc_library(
+    name = "fake_claim_validator",
+    hdrs = ["fake_claim_validator.h"],
+    deps = [
+        "//claim:validator",
+        "//constants:status_codes",
+        "//jumbf:box",
+        "//validator:tracker",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_test(
+    name = "fake_claim_validator_test",
+    srcs = ["fake_claim_validator_test.cc"],
+    deps = [
+        ":fake_claim_validator",
+        ":proto_test_utils",
+        ":test_validation_tracker",
+        "//constants:status_codes",
+        "//jumbf:box",
+        "//proto:generator_info_cc_proto",
+        "//proto:manifest_cc_proto",
+        "@abseil-cpp//absl/types:span",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "fake_assertion_validator",
+    hdrs = ["fake_assertion_validator.h"],
+    deps = [
+        "//assertion:validator",
+        "//constants:status_codes",
+        "//jumbf:uri",
+        "//validator:tracker",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_test(
+    name = "fake_assertion_validator_test",
+    srcs = ["fake_assertion_validator_test.cc"],
+    deps = [
+        ":fake_assertion_validator",
+        ":proto_test_utils",
+        ":test_validation_tracker",
+        "//constants:status_codes",
+        "//jumbf:box",
+        "//jumbf:uri",
+        "//proto:assertion_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "crypto_testing",
+    srcs = ["crypto_testing.cc"],
+    hdrs = ["crypto_testing.h"],
+    deps = [
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/time",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_library(
+    name = "boringssl_utils",
+    testonly = False,
+    srcs = ["boringssl_utils.cc"],
+    hdrs = ["boringssl_utils.h"],
+    deps = [
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_library(
+    name = "cbor_utils",
+    srcs = ["cbor_utils.cc"],
+    hdrs = ["cbor_utils.h"],
+    deps = [
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@libcppbor",
+        "@nlohmann_json//:json",
+    ],
+)
+
+cc_test(
+    name = "cbor_utils_test",
+    srcs = ["cbor_utils_test.cc"],
+    deps = [
+        ":cbor_utils",
+        "//cbor:parse",
+        "//testing/proto:test_message_cc_proto",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@libcppbor",
+        "@nlohmann_json//:json",
+    ],
+)
+
+cc_library(
+    name = "pem",
+    srcs = ["pem.cc"],
+    hdrs = ["pem.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:crypto",
+        "@tink_cc//proto:common_cc_proto",
+        "@tink_cc//tink:cleartext_keyset_handle",
+        "@tink_cc//tink:keyset_handle",
+        "@tink_cc//tink:public_key_sign",
+        "@tink_cc//tink/signature:config_v0",
+        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+    ],
+)
+
+cc_test(
+    name = "pem_test",
+    srcs = ["pem_test.cc"],
+    deps = [
+        ":pem",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+    ],
+)
+
+cc_library(
+    name = "cms",
+    testonly = False,
+    srcs = ["cms.cc"],
+    hdrs = ["cms.h"],
+    deps = [
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_library(
+    name = "jumbf_utils",
+    srcs = ["jumbf_utils.cc"],
+    hdrs = ["jumbf_utils.h"],
+    deps = [
+        "//assertion:assertion_encoder",
+        "//constants:labels",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//jumbf:box",
+        "//jumbf:constants",
+        "//jumbf:parse",
+        "//jumbf:test_utils",
+        "//proto:assertion_cc_proto",
+        "//utils:byte_readers",
+        "//utils:byte_writers",
+        "//uuid",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_library(
+    name = "test_validation_tracker",
+    hdrs = ["test_validation_tracker.h"],
+    deps = [
+        "//constants:status_codes",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//validator:tracker",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest",
+        "@protobuf//:protobuf_lite",
+    ],
+)
+
+cc_library(
+    name = "test_status_tracker",
+    hdrs = ["test_status_tracker.h"],
+    deps = [
+        ":test_validation_tracker",
+        "//constants:status_codes",
+        "//utils:status_tracker",
+        "//validator:tracker",
+    ],
+)
+
+cc_library(
+    name = "proto_test_utils",
+    hdrs = ["proto_test_utils.h"],
+    deps = [
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest",
+        "@protobuf",
+        "@protobuf//:differencer",
+    ],
+)
+
+cc_library(
+    name = "test_file_utils",
+    srcs = ["test_file_utils.cc"],
+    hdrs = ["test_file_utils.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@bazel_tools//tools/cpp/runfiles",
+        "@riegeli//riegeli/bytes:cfile_reader",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
diff --git a/testing/boringssl_utils.cc b/testing/boringssl_utils.cc
new file mode 100644
index 0000000..b9ecc4e
--- /dev/null
+++ b/testing/boringssl_utils.cc
@@ -0,0 +1,71 @@
+// 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 "testing/boringssl_utils.h"
+
+#include <cstdint>
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/log/check.h"
+#include "absl/log/die_if_null.h"
+#include "absl/strings/string_view.h"
+#include "openssl/base.h"
+#include "openssl/bio.h"
+#include "openssl/bytestring.h"
+#include "openssl/mem.h"
+#include "openssl/obj.h"
+#include "openssl/pem.h"
+#include "openssl/x509.h"
+
+namespace credentio {
+
+bssl::UniquePtr<X509> absl_nullable PemToCertificate(absl::string_view pem) {
+  bssl::UniquePtr<BIO> bio(
+      BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size()));
+  X509* x509 = PEM_read_bio_X509(bio.get(), nullptr /* existing key */,
+                                 nullptr /* password callback */,
+                                 nullptr /* password */);
+  return bssl::UniquePtr<X509>(x509);
+}
+
+bssl::UniquePtr<EVP_PKEY> absl_nullable PemToKey(absl::string_view pem) {
+  bssl::UniquePtr<BIO> bio(
+      BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size()));
+  EVP_PKEY* pkey = PEM_read_bio_PrivateKey(
+      bio.get(), nullptr /* existing key */, nullptr /* password callback */,
+      nullptr /* password */);
+  return bssl::UniquePtr<EVP_PKEY>(pkey);
+}
+
+std::string PemToDer(absl::string_view pem) {
+  bssl::UniquePtr<BIO> bio(ABSL_DIE_IF_NULL(
+      BIO_new_mem_buf(const_cast<char*>(pem.data()), pem.size())));
+
+  char* name;
+  char* header;
+  uint8_t* data;
+  long len;  // NOLINT: Can't use `int64_t` because on ARM that becomes long
+             // long which is incompatible with `PEM_read_bio(...)`.
+  CHECK(PEM_read_bio(bio.get(), &name, &header, &data, &len));
+  // Smart pointers for clean-up only.
+  bssl::UniquePtr<char> name_uniq(name);
+  bssl::UniquePtr<char> header_uniq(header);
+  bssl::UniquePtr<uint8_t> data_uniq(data);
+
+  return std::string(reinterpret_cast<const char*>(data), len);
+}
+
+}  // namespace credentio
diff --git a/testing/boringssl_utils.h b/testing/boringssl_utils.h
new file mode 100644
index 0000000..417099e
--- /dev/null
+++ b/testing/boringssl_utils.h
@@ -0,0 +1,41 @@
+// 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.
+//
+
+// Utilities for performing common BoringSSL operations.
+//
+// Primarily intended for tests.
+#ifndef THIRD_PARTY_CREDENTIO_TESTING_BORINGSSL_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_BORINGSSL_UTILS_H_
+
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/strings/string_view.h"
+#include "openssl/base.h"
+
+namespace credentio {
+
+// Parses the first X509 certificate in `pem`. Returns `nullptr` on error.
+bssl::UniquePtr<X509> absl_nullable PemToCertificate(absl::string_view pem);
+
+// Parses the private key in `pem`. Returns `nullptr` on error.
+bssl::UniquePtr<EVP_PKEY> absl_nullable PemToKey(absl::string_view pem);
+
+// Parses the first PEM block found in `pem`. CHECK-fails on error.
+std::string PemToDer(absl::string_view pem);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_BORINGSSL_UTILS_H_
diff --git a/testing/cbor_utils.cc b/testing/cbor_utils.cc
new file mode 100644
index 0000000..bb9ef79
--- /dev/null
+++ b/testing/cbor_utils.cc
@@ -0,0 +1,263 @@
+// 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 "testing/cbor_utils.h"
+
+#include <sys/types.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/check.h"
+#include "absl/log/log.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/strip.h"
+#include "cppbor/cppbor.h"
+#include "cppbor/cppbor_parse.h"
+#include "nlohmann/json.hpp"
+
+namespace cbor {
+namespace {
+
+using Json = ::nlohmann::json;
+
+cppbor::Map B64ToBstr(cppbor::Map* map);
+cppbor::Map BstrToB64(cppbor::Map* map);
+
+constexpr absl::string_view kBase64Prefix = "b64'";
+constexpr absl::string_view kBase64Suffix = "'";
+
+class NoExceptionParser
+    : public nlohmann::detail::json_sax_dom_parser<
+          Json, nlohmann::detail::iterator_input_adapter<const char*>> {
+ public:
+  explicit NoExceptionParser(Json& j)
+      : nlohmann::detail::json_sax_dom_parser<
+            Json, nlohmann::detail::iterator_input_adapter<const char*>>(
+            j, false) {}
+
+  bool parse_error(std::size_t position, absl::string_view last_token,
+                   const Json::exception& ex) {
+    error_ = ex.what();
+    return false;  // Stop processing.
+  }
+
+  std::string error() { return error_; }
+
+ private:
+  std::string error_;
+};
+
+std::string DecodePrefixedBase64(absl::string_view encoded) {
+  auto stripped = absl::StripPrefix(encoded, kBase64Prefix);
+  if (!absl::EndsWith(stripped, kBase64Suffix)) {
+    LOG(FATAL) << "The base64-encoded string " << encoded
+               << " does not end with the suffix " << kBase64Suffix;
+  }
+  stripped = absl::StripSuffix(stripped, kBase64Suffix);
+  std::string decoded;
+  if (!absl::Base64Unescape(stripped, &decoded)) {
+    LOG(FATAL) << "Failed to decode base64: " << stripped;
+  }
+  return decoded;
+}
+
+std::string EncodePrefixedBase64(absl::string_view raw) {
+  return absl::StrCat(kBase64Prefix, absl::Base64Escape(raw), kBase64Suffix);
+}
+
+std::string EncodeCbor(cppbor::Item* item) {
+  std::vector<uint8_t> buf;
+  buf.resize(item->encodedSize());
+  item->encode(buf.data(), buf.data() + buf.size());
+  return std::string(reinterpret_cast<const char*>(buf.data()), buf.size());
+}
+
+// Recursively replaces all base64-encoded Tstr with their decoded values as
+// Bstr.
+cppbor::Array B64ToBstr(cppbor::Array* array) {
+  cppbor::Array new_array;
+  for (auto& item : *array) {
+    if (auto s = item->asTstr();
+        s != nullptr && s->value().starts_with(kBase64Prefix)) {
+      new_array.add(cppbor::Bstr(DecodePrefixedBase64(s->value())));
+    } else if (auto m = item->asMap(); m != nullptr) {
+      new_array.add(B64ToBstr(m));
+    } else if (auto a = item->asArray(); a != nullptr) {
+      new_array.add(B64ToBstr(a));
+    } else {
+      new_array.add(std::move(item));
+    }
+  }
+  return new_array;
+}
+
+// Recursively replaces all base64-encoded Tstr with their decoded values as
+// Bstr.
+cppbor::Map B64ToBstr(cppbor::Map* map) {
+  cppbor::Map new_map;
+  for (auto& [key, value] : *map) {
+    if (auto v = value->asTstr();
+        v != nullptr && v->value().starts_with(kBase64Prefix)) {
+      new_map.add(std::move(key),
+                  cppbor::Bstr(DecodePrefixedBase64(v->value())));
+    } else if (auto m = value->asMap(); m != nullptr) {
+      new_map.add(std::move(key), B64ToBstr(m));
+    } else if (auto v = value->asArray(); v != nullptr) {
+      cppbor::Array new_array = B64ToBstr(v);
+      new_map.add(std::move(key), std::move(new_array));
+    } else {
+      new_map.add(std::move(key), std::move(value));
+    }
+  }
+  return new_map;
+}
+
+// Recursively replaces all base64-encoded Tstr with their decoded values as
+// Bstr. Supports only arrays and maps.
+std::string B64ToBstr(const uint8_t* begin, const uint8_t* end) {
+  auto [item, new_position, error] = cppbor::parse(begin, end);
+  if (!error.empty()) {
+    LOG(FATAL) << "CBOR parsing failed: " << error;
+  }
+  if (new_position != end) {
+    LOG(FATAL) << "Trailing bytes after the parsed CBOR item";
+  }
+
+  if (auto map = item->asMap(); map != nullptr) {
+    auto new_map = B64ToBstr(map);
+    return EncodeCbor(&new_map);
+  } else if (auto array = item->asArray(); array != nullptr) {
+    auto new_array = B64ToBstr(array);
+    return EncodeCbor(&new_array);
+  }
+  LOG(FATAL) << "Input CBOR item is " << item->type()
+             << "; expected array (128) or map (160).";
+}
+
+std::unique_ptr<cppbor::Item> UnwrapTag(std::unique_ptr<cppbor::Item> item) {
+  while (item->asSemanticTag() != nullptr) {
+    if (auto t = item->asTstr(); t != nullptr) return t->clone();
+    if (auto u = item->asUint(); u != nullptr) return u->clone();
+    if (auto i = item->asInt(); i != nullptr) return i->clone();
+    if (auto b = item->asBool(); b != nullptr) return b->clone();
+    if (auto f = item->asFloat(); f != nullptr) return f->clone();
+    if (auto d = item->asDouble(); d != nullptr) return d->clone();
+    if (auto s = item->asSimple(); s != nullptr) return s->clone();
+    break;
+  }
+  return item;
+}
+
+// Recursively replaces all Bstr with base64-encoded Tstr.
+cppbor::Array BstrToB64(cppbor::Array* array) {
+  cppbor::Array new_array;
+  for (auto& item_ref : *array) {
+    auto item = UnwrapTag(std::move(item_ref));
+    if (auto s = item->asBstr(); s != nullptr) {
+      absl::string_view sv(reinterpret_cast<const char*>(s->value().data()),
+                           s->value().size());
+      new_array.add(cppbor::Tstr(EncodePrefixedBase64(sv)));
+    } else if (auto m = item->asMap(); m != nullptr) {
+      new_array.add(BstrToB64(m));
+    } else if (auto a = item->asArray(); a != nullptr) {
+      new_array.add(BstrToB64(a));
+    } else {
+      new_array.add(std::move(item));
+    }
+  }
+  return new_array;
+}
+
+// Recursively replaces all Bstr with base64-encoded Tstr.
+cppbor::Map BstrToB64(cppbor::Map* map) {
+  cppbor::Map new_map;
+  for (auto& [key_ref, value_ref] : *map) {
+    auto key = UnwrapTag(std::move(key_ref));
+    auto value = UnwrapTag(std::move(value_ref));
+    if (auto s = value->asBstr(); s != nullptr) {
+      absl::string_view sv(reinterpret_cast<const char*>(s->value().data()),
+                           s->value().size());
+      new_map.add(std::move(key), cppbor::Tstr(EncodePrefixedBase64(sv)));
+    } else if (auto m = value->asMap(); m != nullptr) {
+      new_map.add(std::move(key), BstrToB64(m));
+    } else if (auto v = value->asArray(); v != nullptr) {
+      cppbor::Array new_array = BstrToB64(v);
+      new_map.add(std::move(key), std::move(new_array));
+    } else {
+      new_map.add(std::move(key), std::move(value));
+    }
+  }
+  return new_map;
+}
+
+// Recursively replaces all Bstr with base64-encoded Tstr. Supports only arrays
+// and maps.
+std::string BstrToB64(absl::string_view raw) {
+  auto [item, new_position, error] =
+      cppbor::parse(reinterpret_cast<const uint8_t*>(raw.data()), raw.length());
+  if (!error.empty()) {
+    LOG(FATAL) << "CBOR parsing failed: " << error;
+  }
+  if (new_position !=
+      reinterpret_cast<const uint8_t*>(raw.data() + raw.length())) {
+    LOG(FATAL) << "Trailing bytes after the parsed CBOR item";
+  }
+
+  if (auto map = item->asMap(); map != nullptr) {
+    auto new_map = BstrToB64(map);
+    return EncodeCbor(&new_map);
+  } else if (auto array = item->asArray(); array != nullptr) {
+    auto new_array = BstrToB64(array);
+    return EncodeCbor(&new_array);
+  }
+  LOG(FATAL) << "Input CBOR item is " << item->type()
+             << "; expected array (128) or map (160).";
+}
+
+}  // namespace
+
+std::string FromJson(absl::string_view json) {
+  Json result;
+  NoExceptionParser parser(result);
+  if (!Json::sax_parse(json, &parser, Json::input_format_t::json)) {
+    LOG(FATAL) << "JSON parse error: " << parser.error();
+  }
+  std::vector<uint8_t> cbor = Json::to_cbor(result);
+  return B64ToBstr(cbor.data(), cbor.data() + cbor.size());
+}
+
+std::string ToJson(absl::string_view cbor) {
+  std::string encoded = BstrToB64(cbor);
+  Json result;
+  NoExceptionParser parser(result);
+  auto ia = nlohmann::detail::input_adapter(encoded);
+  nlohmann::detail::binary_reader<Json, decltype(ia), NoExceptionParser> reader(
+      std::move(ia), Json::input_format_t::cbor);
+  if (!reader.sax_parse(Json::input_format_t::cbor, &parser, /*strict=*/true,
+                        /*tag_handler=*/Json::cbor_tag_handler_t::ignore)) {
+    LOG(FATAL) << "CBOR parse error: " << parser.error();
+  }
+  return result.dump();
+}
+
+}  // namespace cbor
diff --git a/testing/cbor_utils.h b/testing/cbor_utils.h
new file mode 100644
index 0000000..516be2c
--- /dev/null
+++ b/testing/cbor_utils.h
@@ -0,0 +1,38 @@
+// 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_TESTING_CBOR_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_CBOR_UTILS_H_
+
+#include <string>
+
+#include "absl/strings/string_view.h"
+namespace cbor {
+
+// Converts a JSON string to a CBOR string.
+// On malformed input, records error via LOG(FATAL).
+// This also replaces all base64-encoded strings, like "b64'Zm9v'", with their
+// decoded values as byte strings.
+std::string FromJson(absl::string_view json);
+
+// Converts a CBOR string to a JSON string.
+// On malformed input, records error via LOG(FATAL).
+//
+// Byte strings are represented as base64-encoded strings, like "b64'Zm9v'".
+std::string ToJson(absl::string_view cbor);
+
+}  // namespace cbor
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_CBOR_UTILS_H_
diff --git a/testing/cbor_utils_test.cc b/testing/cbor_utils_test.cc
new file mode 100644
index 0000000..51e9b5a
--- /dev/null
+++ b/testing/cbor_utils_test.cc
@@ -0,0 +1,242 @@
+// 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 "testing/cbor_utils.h"
+
+#include <string>
+#include <utility>
+
+#include "absl/strings/string_view.h"
+#include "cbor/parse.h"
+#include "cppbor/cppbor.h"
+#include "gtest/gtest.h"
+#include "nlohmann/json.hpp"
+#include "testing/proto/test_message.pb.h"
+
+namespace {
+
+constexpr absl::string_view kTestJsonMap = R"json({
+    "padding": "b64'TQ=='",
+    "foo": "b64'Zm9v'",
+    "bar": "bar",
+    "array": ["b64'Zm9v'", "b64'YmFy'", 1, "bar"],
+    "map": {"foo": "b64'Zm9v'", "bar": "bar"},
+    "array_map": [{"foo": "b64'Zm9v'", "bar": "bar"}, {"foo": "b64'Zm9v'", "bar": "bar"}],
+    "array_array": [["b64'Zm9v'", "b64'YmFy'", 1, "bar"], ["b64'Zm9v'", "b64'YmFy'", 1, "bar"]]
+  })json";
+
+constexpr absl::string_view kTestJsonArray =
+    R"json(["b64'Zm9v'", "b64'YmFy'", 1, "bar"])json";
+
+TEST(CborUtilsTest, JsonToCborToJson_NoB64) {
+  auto json = R"json({
+    "foo": "bar"
+  })json";
+  auto cbor = cbor::FromJson(json);
+  auto decoded_json = cbor::ToJson(cbor);
+  EXPECT_EQ(nlohmann::json::parse(decoded_json), nlohmann::json::parse(json));
+}
+
+TEST(CborUtilsTest, JsonToCborToJson_MapWithB64) {
+  auto cbor = cbor::FromJson(kTestJsonMap);
+  EXPECT_EQ(nlohmann::json::parse(cbor::ToJson(cbor)),
+            nlohmann::json::parse(kTestJsonMap));
+}
+
+TEST(CborUtilsTest, JsonToCborToJson_ArrayWithB64) {
+  auto cbor = cbor::FromJson(kTestJsonArray);
+  EXPECT_EQ(nlohmann::json::parse(cbor::ToJson(cbor)),
+            nlohmann::json::parse(kTestJsonArray));
+}
+
+TEST(CborUtilsTest, ToJson_WithSemanticTag) {
+  // 0xC0 is Tag 0 (RFC 3339 date/time string).
+  // Construct a CBOR map {"when": 0xC0 "2026-05-19T00:00:00Z"}.
+  cppbor::Map orig_map;
+  orig_map.add("when",
+               cppbor::SemanticTag(0, cppbor::Tstr("2026-05-19T00:00:00Z")));
+  auto bytes = orig_map.encode();
+  absl::string_view bytes_view(reinterpret_cast<const char*>(bytes.data()),
+                               bytes.size());
+  std::string json = cbor::ToJson(bytes_view);
+  EXPECT_EQ(
+      nlohmann::json::parse(json),
+      nlohmann::json::parse(R"json({"when": "2026-05-19T00:00:00Z"})json"));
+}
+
+TEST(CborUtilsTest, JsonToCbor_MapWithB64) {
+  auto cbor = cbor::FromJson(kTestJsonMap);
+  auto value1 = cbor::Parse(cbor);
+  ASSERT_TRUE(value1.ok());
+  auto result = std::move(value1).value();
+  auto value2 = result->AsMap();
+  ASSERT_TRUE(value2.ok());
+  auto map = std::move(value2).value();
+  auto value3 = map.GetByteString("padding");
+  ASSERT_TRUE(value3.ok());
+  auto padding = std::move(value3).value();
+  EXPECT_EQ(padding, "M");
+  auto value4 = map.GetByteString("foo");
+  ASSERT_TRUE(value4.ok());
+  auto foo = std::move(value4).value();
+  EXPECT_EQ(foo, "foo");
+  auto value5 = map.GetString("bar");
+  ASSERT_TRUE(value5.ok());
+  auto bar = std::move(value5).value();
+  EXPECT_EQ(bar, "bar");
+
+  auto value6 = map.GetArray("array");
+  ASSERT_TRUE(value6.ok());
+  auto array = std::move(value6).value();
+  ASSERT_EQ(array.size(), 4);
+  auto value7 = array.GetByteString(0);
+  ASSERT_TRUE(value7.ok());
+  auto array_foo = std::move(value7).value();
+  EXPECT_EQ(array_foo, "foo");
+  auto value8 = array.GetByteString(1);
+  ASSERT_TRUE(value8.ok());
+  auto array_bar = std::move(value8).value();
+  EXPECT_EQ(array_bar, "bar");
+  auto value9 = array.GetUint64(2);
+  ASSERT_TRUE(value9.ok());
+  auto array_1 = std::move(value9).value();
+  EXPECT_EQ(array_1, 1);
+  auto value10 = array.GetString(3);
+  ASSERT_TRUE(value10.ok());
+  auto array_bar_2 = std::move(value10).value();
+  EXPECT_EQ(array_bar_2, "bar");
+
+  auto value11 = map.GetMap("map");
+  ASSERT_TRUE(value11.ok());
+  auto map_map = std::move(value11).value();
+  auto value12 = map_map.GetByteString("foo");
+  ASSERT_TRUE(value12.ok());
+  auto map_foo = std::move(value12).value();
+  EXPECT_EQ(map_foo, "foo");
+  auto value13 = map_map.GetString("bar");
+  ASSERT_TRUE(value13.ok());
+  auto map_bar = std::move(value13).value();
+  EXPECT_EQ(map_bar, "bar");
+
+  auto value14 = map.GetArray("array_map");
+  ASSERT_TRUE(value14.ok());
+  auto array_map = std::move(value14).value();
+  ASSERT_EQ(array_map.size(), 2);
+  auto value15 = array_map.GetMap(0);
+  ASSERT_TRUE(value15.ok());
+  auto array_map_0 = std::move(value15).value();
+  auto value16 = array_map_0.GetByteString("foo");
+  ASSERT_TRUE(value16.ok());
+  auto array_map_foo_0 = std::move(value16).value();
+  EXPECT_EQ(array_map_foo_0, "foo");
+  auto value17 = array_map_0.GetString("bar");
+  ASSERT_TRUE(value17.ok());
+  auto array_map_bar_0 = std::move(value17).value();
+  EXPECT_EQ(array_map_bar_0, "bar");
+  auto value18 = array_map.GetMap(1);
+  ASSERT_TRUE(value18.ok());
+  auto array_map_1 = std::move(value18).value();
+  auto value19 = array_map_1.GetByteString("foo");
+  ASSERT_TRUE(value19.ok());
+  auto array_map_foo_1 = std::move(value19).value();
+  EXPECT_EQ(array_map_foo_1, "foo");
+  auto value20 = array_map_1.GetString("bar");
+  ASSERT_TRUE(value20.ok());
+  auto array_map_bar_1 = std::move(value20).value();
+  EXPECT_EQ(array_map_bar_1, "bar");
+
+  auto value21 = map.GetArray("array_array");
+  ASSERT_TRUE(value21.ok());
+  auto array_array = std::move(value21).value();
+  ASSERT_EQ(array_array.size(), 2);
+  auto value22 = array_array.GetArray(0);
+  ASSERT_TRUE(value22.ok());
+  auto array_array_0 = std::move(value22).value();
+  ASSERT_EQ(array_array_0.size(), 4);
+  auto value23 = array_array_0.GetByteString(0);
+  ASSERT_TRUE(value23.ok());
+  auto array_array_foo_0 = std::move(value23).value();
+  EXPECT_EQ(array_array_foo_0, "foo");
+  auto value24 = array_array_0.GetString(3);
+  ASSERT_TRUE(value24.ok());
+  auto array_array_bar_0 = std::move(value24).value();
+  EXPECT_EQ(array_array_bar_0, "bar");
+  auto value25 = array_array.GetArray(1);
+  ASSERT_TRUE(value25.ok());
+  auto array_array_1 = std::move(value25).value();
+  ASSERT_EQ(array_array_1.size(), 4);
+  auto value26 = array_array_1.GetByteString(0);
+  ASSERT_TRUE(value26.ok());
+  auto array_array_foo_1 = std::move(value26).value();
+  EXPECT_EQ(array_array_foo_1, "foo");
+  auto value27 = array_array_1.GetString(3);
+  ASSERT_TRUE(value27.ok());
+  auto array_array_bar_1 = std::move(value27).value();
+  EXPECT_EQ(array_array_bar_1, "bar");
+}
+
+TEST(CborUtilsTest, JsonToCbor_ArrayWithB64) {
+  auto cbor = cbor::FromJson(kTestJsonArray);
+  auto value28 = cbor::Parse(cbor);
+  ASSERT_TRUE(value28.ok());
+  auto result = std::move(value28).value();
+  auto value29 = result->AsArray();
+  ASSERT_TRUE(value29.ok());
+  auto array = std::move(value29).value();
+  ASSERT_EQ(array.size(), 4);
+  auto value30 = array.GetByteString(0);
+  ASSERT_TRUE(value30.ok());
+  auto array_foo = std::move(value30).value();
+  EXPECT_EQ(array_foo, "foo");
+  auto value31 = array.GetByteString(1);
+  ASSERT_TRUE(value31.ok());
+  auto array_bar = std::move(value31).value();
+  EXPECT_EQ(array_bar, "bar");
+  auto value32 = array.GetUint64(2);
+  ASSERT_TRUE(value32.ok());
+  auto array_1 = std::move(value32).value();
+  EXPECT_EQ(array_1, 1);
+  auto value33 = array.GetString(3);
+  ASSERT_TRUE(value33.ok());
+  auto array_bar_2 = std::move(value33).value();
+  EXPECT_EQ(array_bar_2, "bar");
+}
+
+TEST(CborUtilsTest, JsonToCbor_ErrorMalformedBase64Decoding) {
+  auto json = R"json({
+    "foo": "b64'Zm9v`'"
+  })json";
+  EXPECT_DEATH({ cbor::FromJson(json); }, "Failed to decode base64: Zm9v`");
+}
+
+TEST(CborUtilsTest, JsonToCbor_ErrorMalformedBase64Format) {
+  auto json = R"json({
+    "foo": "b64'Zm9v"
+  })json";
+  EXPECT_DEATH(
+      { cbor::FromJson(json); },
+      "The base64-encoded string b64'Zm9v does not end with the suffix '");
+}
+
+TEST(CborUtilsDeathTest, JsonToCbor_Malformed) {
+  EXPECT_DEATH(
+      { cbor::FromJson("malformed"); }, "JSON parse error.*syntax error");
+}
+
+TEST(CborUtilsDeathTest, CborToJson_Malformed) {
+  EXPECT_DEATH({ cbor::ToJson("malformed"); }, "CBOR parsing failed");
+}
+
+}  // namespace
diff --git a/testing/cms.cc b/testing/cms.cc
new file mode 100644
index 0000000..b73dd6d
--- /dev/null
+++ b/testing/cms.cc
@@ -0,0 +1,389 @@
+// 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 "testing/cms.h"
+
+#include <cstdint>
+#include <ctime>
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/functional/function_ref.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+#include "openssl/mem.h"
+#include "openssl/obj.h"
+#include "openssl/x509.h"
+
+namespace credentio_testing {
+
+namespace {
+
+constexpr absl::string_view kOidMessageDigest = "1.2.840.113549.1.9.4";
+constexpr absl::string_view kOidContentType = "1.2.840.113549.1.9.3";
+constexpr absl::string_view kOidTstInfo = "1.2.840.113549.1.9.16.1.4";
+constexpr absl::string_view kOidData = "1.2.840.113549.1.7.1";
+constexpr absl::string_view kOidSigningTime = "1.2.840.113549.1.9.5";
+constexpr absl::string_view kOidSmimeCaps = "1.2.840.113549.1.9.15";
+constexpr absl::string_view kOidAes256Cbc = "2.16.840.1.101.3.4.1.42";
+constexpr absl::string_view kOidAes192Cbc = "2.16.840.1.101.3.4.1.22";
+constexpr absl::string_view kOidAes128Cbc = "2.16.840.1.101.3.4.1.2";
+constexpr absl::string_view kOidDesEde3Cbc = "1.2.840.113549.3.7";
+constexpr absl::string_view kOidRsaesOaep = "1.2.840.113549.1.1.7";
+constexpr absl::string_view kOidSha256 = "2.16.840.1.101.3.4.2.1";
+constexpr absl::string_view kOidSignedData = "1.2.840.113549.1.7.2";
+constexpr absl::string_view kOidRsaEncryption = "1.2.840.113549.1.1.1";
+
+bool SerializeCertificate(const X509& certificate, std::vector<uint8_t>* data) {
+  int len = i2d_X509(&certificate, nullptr);
+  if (len <= 0) {
+    return false;
+  }
+  data->resize(len);
+  uint8_t* dataptr = data->data();
+  return i2d_X509(&certificate, &dataptr) == len;
+}
+
+bool SerializeCertificateIssuerName(const X509& certificate,
+                                    std::vector<uint8_t>* data) {
+  int len = i2d_X509_NAME(X509_get_issuer_name(&certificate), nullptr);
+  if (len <= 0) {
+    return false;
+  }
+  data->resize(len);
+  uint8_t* dataptr = data->data();
+  return i2d_X509_NAME(X509_get_issuer_name(&certificate), &dataptr) == len;
+}
+
+bool SerializeCertificateSerialNumber(const X509& certificate,
+                                      std::vector<uint8_t>* data) {
+  const ASN1_INTEGER* serial_asn1 = X509_get0_serialNumber(&certificate);
+  if (serial_asn1 == nullptr) {
+    return false;
+  }
+  int len = i2d_ASN1_INTEGER(serial_asn1, nullptr);
+  if (len <= 0) {
+    return false;
+  }
+  data->resize(len);
+  uint8_t* dataptr = data->data();
+  return i2d_ASN1_INTEGER(serial_asn1, &dataptr) == len;
+}
+
+bool SerializeSigningTime(time_t signing_time, std::vector<uint8_t>* data) {
+  bssl::UniquePtr<ASN1_TIME> asn1_time(ASN1_TIME_set(nullptr, signing_time));
+  if (asn1_time == nullptr) {
+    return false;
+  }
+  int len = i2d_ASN1_TIME(asn1_time.get(), nullptr);
+  if (len <= 0) {
+    return false;
+  }
+  data->resize(len);
+  uint8_t* dataptr = data->data();
+  return i2d_ASN1_TIME(asn1_time.get(), &dataptr) == len;
+}
+
+bool AddOid(CBB* cbb, absl::string_view oid) {
+  return CBB_add_asn1_oid_from_text(cbb, oid.data(), oid.length());
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> CreateSignedCms(
+    absl::Span<const absl::string_view> contents,
+    const X509& signer_certificate,
+    absl::FunctionRef<absl::StatusOr<std::vector<uint8_t>>(absl::string_view)>
+        sign_callback,
+    const SignedCmsOptions& options) {
+  ABSL_ASSIGN_OR_RETURN(auto hasher,
+                        CreateHasher(credentio::HashAlgorithm::kSha256));
+  for (const auto& part : contents) {
+    hasher->Update(part);
+  }
+  std::string hash = hasher->Digest();
+
+  std::vector<uint8_t> signing_time_der, issuer_name, serial_number;
+  if (!SerializeCertificateIssuerName(signer_certificate, &issuer_name) ||
+      !SerializeCertificateSerialNumber(signer_certificate, &serial_number) ||
+      !SerializeSigningTime(absl::ToUnixSeconds(options.signing_time),
+                            &signing_time_der)) {
+    return absl::InternalError("Failed to serialize certificate components.");
+  }
+
+  bssl::ScopedCBB attributes;
+  CBB att_seq, oid, value_set, value;
+  if (CBB_init(attributes.get(), 1024) != 1) {
+    return absl::InternalError("Memory allocation failed for attributes.");
+  }
+
+  // Message Digest
+  if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidMessageDigest) ||
+      CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 ||
+      CBB_add_asn1(&value_set, &value, CBS_ASN1_OCTETSTRING) != 1 ||
+      CBB_add_bytes(&value, reinterpret_cast<const uint8_t*>(hash.data()),
+                    hash.size()) != 1) {
+    return absl::InternalError("Failed to add message digest attribute.");
+  }
+
+  // Content Type
+  if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidContentType) ||
+      CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 ||
+      CBB_add_asn1(&value_set, &value, CBS_ASN1_OBJECT) != 1) {
+    return absl::InternalError("Failed to add content type attribute.");
+  }
+  if (options.is_time_stamp_token) {
+    if (!AddOid(&value, kOidTstInfo)) {
+      return absl::InternalError("Failed to add TSTInfo OID.");
+    }
+  } else {
+    if (!AddOid(&value, kOidData)) {
+      return absl::InternalError("Failed to add data OID.");
+    }
+  }
+
+  // Signing Time
+  if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidSigningTime) ||
+      CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 ||
+      CBB_add_bytes(&value_set, signing_time_der.data(),
+                    signing_time_der.size()) != 1) {
+    return absl::InternalError("Failed to add signing time attribute.");
+  }
+
+  // SMIME-CAPS
+  CBB cap_seq, cap_item;
+  if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidSmimeCaps) ||
+      CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 ||
+      CBB_add_asn1(&value_set, &cap_seq, CBS_ASN1_SEQUENCE) != 1) {
+    return absl::InternalError("Failed to add SMIME-CAPS attribute.");
+  }
+
+  // AES-256-CBC
+  if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidAes256Cbc)) {
+    return absl::InternalError("Failed to add AES-256-CBC capability.");
+  }
+  // AES-192-CBC
+  if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidAes192Cbc)) {
+    return absl::InternalError("Failed to add AES-192-CBC capability.");
+  }
+  // AES-128-CBC
+  if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidAes128Cbc)) {
+    return absl::InternalError("Failed to add AES-128-CBC capability.");
+  }
+  // DES-EDE3-CBC
+  if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidDesEde3Cbc)) {
+    return absl::InternalError("Failed to add DES-EDE3-CBC capability.");
+  }
+  // RSAES-OAEP
+  if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidRsaesOaep)) {
+    return absl::InternalError("Failed to add RSAES-OAEP capability.");
+  }
+  // SHA256
+  if (CBB_add_asn1(&cap_seq, &cap_item, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&cap_item, &oid, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&oid, kOidSha256)) {
+    return absl::InternalError("Failed to add SHA256 capability.");
+  }
+
+  // Extra Signed Attributes
+  if (!options.extra_signed_attributes.empty()) {
+    if (CBB_add_bytes(attributes.get(),
+                      reinterpret_cast<const uint8_t*>(
+                          options.extra_signed_attributes.data()),
+                      options.extra_signed_attributes.length()) != 1) {
+      return absl::InternalError("Failed to add extra signed attributes.");
+    }
+  }
+
+  uint8_t* raw_attr_data;
+  size_t raw_attr_len;
+  if (CBB_finish(attributes.get(), &raw_attr_data, &raw_attr_len) != 1) {
+    return absl::InternalError("Failed to finish attributes CBB.");
+  }
+  bssl::UniquePtr<uint8_t> raw_attr_data_uniq(raw_attr_data);
+
+  // Sign the attributes. The signature is computed over the SET of attributes.
+  bssl::ScopedCBB pk_signed_attributes;
+  CBB pk_attributes_set;
+  if (CBB_init(pk_signed_attributes.get(), raw_attr_len + 5) != 1 ||
+      CBB_add_asn1(pk_signed_attributes.get(), &pk_attributes_set,
+                   CBS_ASN1_SET) != 1 ||
+      CBB_add_bytes(&pk_attributes_set, raw_attr_data, raw_attr_len) != 1 ||
+      CBB_flush(pk_signed_attributes.get()) != 1) {
+    return absl::InternalError("Failed to prepare attributes for signing.");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(
+      std::vector<uint8_t> signature,
+      sign_callback(absl::string_view(
+          reinterpret_cast<const char*>(CBB_data(pk_signed_attributes.get())),
+          CBB_len(pk_signed_attributes.get()))));
+
+  // Assemble CMS
+  bssl::ScopedCBB cms;
+  CBB content_type, signed_data, digest_algs, digest_alg_seq, content_seq,
+      certs, algo_params, temp, signed_seq;
+  if (CBB_init(cms.get(), 2048) != 1) {
+    return absl::InternalError("Memory allocation for CMS failed.");
+  }
+
+  uint64_t version = options.is_time_stamp_token ? 3 : 1;
+
+  if (CBB_add_asn1(cms.get(), &content_type, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&content_type, &temp, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&temp, kOidSignedData) ||
+      CBB_add_asn1(&content_type, &signed_data,
+                   CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC) != 1 ||
+      CBB_add_asn1(&signed_data, &signed_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      // Version
+      CBB_add_asn1_uint64(&signed_seq, version) != 1 ||
+      // Digest algorithms
+      CBB_add_asn1(&signed_seq, &digest_algs, CBS_ASN1_SET) != 1 ||
+      CBB_add_asn1(&digest_algs, &digest_alg_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&digest_alg_seq, &temp, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&temp, kOidSha256) ||
+      CBB_add_asn1(&digest_alg_seq, &algo_params, CBS_ASN1_NULL) != 1 ||
+      // Content
+      CBB_add_asn1(&signed_seq, &content_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&content_seq, &temp, CBS_ASN1_OBJECT) != 1) {
+    return absl::InternalError("Failed to build CMS header.");
+  }
+
+  if (options.is_time_stamp_token) {
+    if (!AddOid(&temp, kOidTstInfo)) {
+      return absl::InternalError("Failed to add TSTInfo OID to header.");
+    }
+  } else {
+    if (!AddOid(&temp, kOidData)) {
+      return absl::InternalError("Failed to add data OID to header.");
+    }
+  }
+
+  // Opaque Content
+  CBB octet_stream;
+  if (CBB_add_asn1(&content_seq, &octet_stream,
+                   CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC) != 1 ||
+      CBB_add_asn1(&octet_stream, &temp, CBS_ASN1_OCTETSTRING) != 1) {
+    return absl::InternalError("Failed to add opaque content wrapper.");
+  }
+  for (const auto& part : contents) {
+    if (CBB_add_bytes(&temp, reinterpret_cast<const uint8_t*>(part.data()),
+                      part.size()) != 1) {
+      return absl::InternalError("Failed to add opaque content bytes.");
+    }
+  }
+
+  // Certificates
+  if (options.include_user_certificate || !options.ca_certificates.empty()) {
+    if (CBB_add_asn1(&signed_seq, &certs,
+                     CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC) != 1) {
+      return absl::InternalError("Failed to add certificates wrapper.");
+    }
+    std::vector<uint8_t> der;
+    for (const X509* cert : options.ca_certificates) {
+      if (!SerializeCertificate(*cert, &der)) {
+        return absl::InternalError("Failed to serialize CA certificate.");
+      }
+      if (CBB_add_bytes(&certs, der.data(), der.size()) != 1) {
+        return absl::InternalError("Failed to add CA certificate.");
+      }
+    }
+    if (options.include_user_certificate) {
+      if (!SerializeCertificate(signer_certificate, &der)) {
+        return absl::InternalError("Failed to serialize user certificate.");
+      }
+      if (CBB_add_bytes(&certs, der.data(), der.size()) != 1) {
+        return absl::InternalError("Failed to add user certificate.");
+      }
+    }
+  }
+
+  // Signer Info
+  CBB signer_info_set, signer_info, issuer_and_sn;
+  if (CBB_add_asn1(&signed_seq, &signer_info_set, CBS_ASN1_SET) != 1 ||
+      CBB_add_asn1(&signer_info_set, &signer_info, CBS_ASN1_SEQUENCE) != 1 ||
+      // Version
+      CBB_add_asn1_uint64(&signer_info, 1) != 1 ||
+      CBB_add_asn1(&signer_info, &issuer_and_sn, CBS_ASN1_SEQUENCE) != 1 ||
+      // Signer info: Signer identifier: Issuer and serial number
+      CBB_add_bytes(&issuer_and_sn, issuer_name.data(), issuer_name.size()) !=
+          1 ||
+      CBB_add_bytes(&issuer_and_sn, serial_number.data(),
+                    serial_number.size()) != 1 ||
+      // Signer info: Digest algorithm
+      CBB_add_asn1(&signer_info, &digest_alg_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&digest_alg_seq, &temp, CBS_ASN1_OBJECT) != 1 ||
+      !AddOid(&temp, kOidSha256) ||
+      CBB_add_asn1(&digest_alg_seq, &algo_params, CBS_ASN1_NULL) != 1 ||
+      // Signer info: Signed attributes
+      CBB_add_asn1(&signer_info, &temp,
+                   CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC) != 1 ||
+      CBB_add_bytes(&temp, raw_attr_data, raw_attr_len) != 1 ||
+      // Signer info: Signature algorithm
+      CBB_add_asn1(&signer_info, &digest_alg_seq, CBS_ASN1_SEQUENCE) != 1 ||
+      CBB_add_asn1(&digest_alg_seq, &temp, CBS_ASN1_OBJECT) != 1) {
+    return absl::InternalError("Failed to build SignerInfo.");
+  }
+
+  // Hardcode RsaEncryption for now to match simple_cms behavior in tests, as
+  // TimestampVerifier doesn't strictly check this.
+  if (!AddOid(&temp, kOidRsaEncryption) ||
+      CBB_add_asn1(&digest_alg_seq, &algo_params, CBS_ASN1_NULL) != 1 ||
+      // Signer info: Signature
+      CBB_add_asn1(&signer_info, &temp, CBS_ASN1_OCTETSTRING) != 1 ||
+      CBB_add_bytes(&temp, signature.data(), signature.size()) != 1) {
+    return absl::InternalError("Failed to add signature to SignerInfo.");
+  }
+
+  uint8_t* cms_data;
+  size_t cms_len;
+  if (CBB_finish(cms.get(), &cms_data, &cms_len) != 1) {
+    return absl::InternalError("Failed to finish CMS CBB.");
+  }
+  bssl::UniquePtr<uint8_t> cms_data_uniq(cms_data);
+
+  return std::string(reinterpret_cast<char*>(cms_data), cms_len);
+}
+
+}  // namespace credentio_testing
diff --git a/testing/cms.h b/testing/cms.h
new file mode 100644
index 0000000..98c944a
--- /dev/null
+++ b/testing/cms.h
@@ -0,0 +1,52 @@
+// 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_TESTING_CMS_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_CMS_H_
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/functional/function_ref.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "openssl/base.h"
+
+namespace credentio_testing {
+
+struct SignedCmsOptions {
+  absl::Time signing_time = absl::UnixEpoch();
+  bool include_user_certificate = false;
+  absl::Span<const X509* const> ca_certificates = {};
+  absl::string_view extra_signed_attributes = "";
+  bool is_time_stamp_token = false;
+};
+
+// Creates a minimal signed CMS structure (SignedData) wrapping the given
+// contents. This is a simplified implementation intended for testing and
+// internal use.
+absl::StatusOr<std::string> CreateSignedCms(
+    absl::Span<const absl::string_view> contents,
+    const X509& signer_certificate,
+    absl::FunctionRef<absl::StatusOr<std::vector<uint8_t>>(absl::string_view)>
+        sign_callback,
+    const SignedCmsOptions& options = {});
+
+}  // namespace credentio_testing
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_CMS_H_
diff --git a/testing/crypto_testing.cc b/testing/crypto_testing.cc
new file mode 100644
index 0000000..7df4c10
--- /dev/null
+++ b/testing/crypto_testing.cc
@@ -0,0 +1,140 @@
+// 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 "testing/crypto_testing.h"
+
+#include <cstddef>
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/time/time.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/bn.h"
+#include "openssl/digest.h"
+#include "openssl/ec_key.h"
+#include "openssl/evp.h"
+#include "openssl/rsa.h"
+#include "openssl/x509.h"
+
+namespace credentio {
+
+bssl::UniquePtr<EVP_PKEY> CreateTestEcKey(int nid) {
+  bssl::UniquePtr<EC_KEY> key(EC_KEY_new_by_curve_name(nid));
+  CHECK_NE(key, nullptr);
+
+  CHECK(EC_KEY_generate_key(key.get()));
+
+  bssl::UniquePtr<EVP_PKEY> pkey(EVP_PKEY_new());
+  CHECK_NE(pkey, nullptr);
+  CHECK(EVP_PKEY_set1_EC_KEY(pkey.get(), key.get()));
+  return pkey;
+}
+
+bssl::UniquePtr<EVP_PKEY> CreateTestRsaKey(size_t num_bits) {
+  bssl::UniquePtr<BIGNUM> rsa_f4(BN_new());
+  CHECK_NE(rsa_f4, nullptr);
+  CHECK(BN_set_word(rsa_f4.get(), RSA_F4));
+
+  bssl::UniquePtr<RSA> rsa(RSA_new());
+  CHECK_NE(rsa, nullptr);
+
+  CHECK(RSA_generate_key_ex(rsa.get(), num_bits, rsa_f4.get(), nullptr));
+
+  bssl::UniquePtr<EVP_PKEY> pkey(EVP_PKEY_new());
+  CHECK_NE(pkey, nullptr);
+  CHECK(EVP_PKEY_set1_RSA(pkey.get(), rsa.get()));
+  return pkey;
+}
+
+bssl::UniquePtr<EVP_PKEY> CreateTestEdKey() {
+  bssl::UniquePtr<EVP_PKEY_CTX> ctx(
+      EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr));
+  CHECK_NE(ctx, nullptr);
+  CHECK(EVP_PKEY_keygen_init(ctx.get()));
+  EVP_PKEY* pkey = nullptr;
+  CHECK(EVP_PKEY_keygen(ctx.get(), &pkey));
+  return bssl::UniquePtr<EVP_PKEY>(pkey);
+}
+
+bssl::UniquePtr<X509> CreateTestCertificate(
+    const TestCertificateOptions& options) {
+  // Options validation.
+  CHECK(!options.subject_common_name.empty());
+  CHECK(!options.issuer_common_name.empty());
+  CHECK(options.signing_key != nullptr);
+  CHECK(options.private_key != nullptr);
+
+  // Create the X509 object.
+  bssl::UniquePtr<X509> x509(X509_new());
+  CHECK(X509_set_version(x509.get(), 2));
+
+  // Set the not_before/not_after fields.
+  bssl::UniquePtr<ASN1_TIME> not_before(ASN1_TIME_new());
+  CHECK(ASN1_TIME_set(not_before.get(), absl::ToTimeT(options.not_before)));
+  CHECK(X509_set1_notBefore(x509.get(), not_before.get()));
+
+  bssl::UniquePtr<ASN1_TIME> not_after(ASN1_TIME_new());
+  CHECK(ASN1_TIME_set(not_after.get(), absl::ToTimeT(options.not_after)));
+  CHECK(X509_set1_notAfter(x509.get(), not_after.get()));
+
+  // Set the subject DN.
+  bssl::UniquePtr<X509_NAME> subject_name(X509_NAME_new());
+  CHECK(X509_NAME_add_entry_by_txt(subject_name.get(), /*field=*/"CN",
+                                   MBSTRING_ASC,
+                                   reinterpret_cast<const unsigned char*>(
+                                       options.subject_common_name.c_str()),
+                                   /*len=*/-1, /*loc=*/-1,
+                                   /*set=*/0));
+  CHECK(X509_NAME_add_entry_by_txt(
+      subject_name.get(), /*field=*/"O", MBSTRING_ASC,
+      reinterpret_cast<const unsigned char*>("Google"),
+      /*len=*/-1, /*loc=*/-1,
+      /*set=*/0));
+  CHECK(X509_NAME_add_entry_by_txt(
+      subject_name.get(), /*field=*/"OU", MBSTRING_ASC,
+      reinterpret_cast<const unsigned char*>("Google OU"),
+      /*len=*/-1, /*loc=*/-1,
+      /*set=*/0));
+  CHECK(X509_set_subject_name(x509.get(), subject_name.get()));
+
+  // Set the issuer DN.
+  bssl::UniquePtr<X509_NAME> issuer_name(X509_NAME_new());
+  CHECK(X509_NAME_add_entry_by_txt(issuer_name.get(), /*field=*/"CN",
+                                   MBSTRING_ASC,
+                                   reinterpret_cast<const unsigned char*>(
+                                       options.issuer_common_name.c_str()),
+                                   /*len=*/-1, /*loc=*/-1,
+                                   /*set=*/0));
+  CHECK(X509_NAME_add_entry_by_txt(
+      issuer_name.get(), /*field=*/"O", MBSTRING_ASC,
+      reinterpret_cast<const unsigned char*>("Google"),
+      /*len=*/-1, /*loc=*/-1,
+      /*set=*/0));
+  CHECK(X509_NAME_add_entry_by_txt(
+      issuer_name.get(), /*field=*/"OU", MBSTRING_ASC,
+      reinterpret_cast<const unsigned char*>("Google OU"),
+      /*len=*/-1, /*loc=*/-1,
+      /*set=*/0));
+  CHECK(X509_set_issuer_name(x509.get(), issuer_name.get()));
+
+  // Set the public key and sign the certificate.
+  CHECK(X509_set_pubkey(x509.get(), options.private_key));
+  CHECK(X509_sign(x509.get(), options.signing_key, EVP_sha256()));
+
+  return x509;
+}
+
+}  // namespace credentio
diff --git a/testing/crypto_testing.h b/testing/crypto_testing.h
new file mode 100644
index 0000000..946cf4e
--- /dev/null
+++ b/testing/crypto_testing.h
@@ -0,0 +1,50 @@
+// 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.
+//
+
+// Utilities for testing crypto code.
+#ifndef THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_
+
+#include <cstddef>
+#include <string>
+
+#include "absl/time/time.h"
+#include "openssl/base.h"
+#include "openssl/x509.h"
+
+namespace credentio {
+
+struct TestCertificateOptions {
+  std::string subject_common_name;
+  std::string issuer_common_name;
+  absl::Time not_before = absl::FromUnixSeconds(1);
+  absl::Time not_after = absl::FromUnixSeconds(86400L * 365 * 100);
+  EVP_PKEY* signing_key = nullptr;
+  EVP_PKEY* private_key = nullptr;
+};
+
+// Generates an X.509 certificate based on `options`.
+bssl::UniquePtr<X509> CreateTestCertificate(
+    const TestCertificateOptions& options);
+
+bssl::UniquePtr<EVP_PKEY> CreateTestEcKey(int nid);
+
+bssl::UniquePtr<EVP_PKEY> CreateTestRsaKey(size_t num_bits);
+
+bssl::UniquePtr<EVP_PKEY> CreateTestEdKey();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_
diff --git a/testing/fake_assertion_validator.h b/testing/fake_assertion_validator.h
new file mode 100644
index 0000000..89efa5f
--- /dev/null
+++ b/testing/fake_assertion_validator.h
@@ -0,0 +1,75 @@
+// 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_TESTING_FAKE_ASSERTION_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_FAKE_ASSERTION_VALIDATOR_H_
+
+#include <string>
+#include <vector>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "assertion/validator.h"
+#include "constants/status_codes.h"
+#include "jumbf/uri.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+class FakeAssertionValidator : public AssertionValidator {
+ public:
+  FakeAssertionValidator(absl::Span<const FailureStatusCode> failures,
+                         absl::Span<const SuccessStatusCode> successes)
+      : failures_(failures.begin(), failures.end()),
+        successes_(successes.begin(), successes.end()) {}
+  void ValidateClaimAssertions(
+      const jumbf::UriResolver& uri_resolver,
+      const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+      Manifest& manifest,
+      ValidationTracker& validation_tracker) const override {
+    // Record failures and successes.
+    for (const auto& failure : failures_) {
+      validation_tracker.RecordFailure(failure, {});
+    }
+    for (const auto& success : successes_) {
+      validation_tracker.RecordSuccess(success, {});
+    }
+    // Populate `assertions_out` with assertions for the given manifest path.
+    std::string manifest_path = absl::StrCat("/c2pa/", manifest.label());
+    if (assertions_.contains(manifest_path)) {
+      for (const auto& assertion : assertions_.at(manifest_path)) {
+        *manifest.add_assertions() = assertion;
+      }
+    }
+  }
+
+  void SetOutputAssertions(absl::string_view manifest_path,
+                           absl::Span<const Assertion> assertions) {
+    assertions_[manifest_path] =
+        std::vector<Assertion>(assertions.begin(), assertions.end());
+  }
+
+ private:
+  std::vector<FailureStatusCode> failures_;
+  std::vector<SuccessStatusCode> successes_;
+  absl::flat_hash_map<std::string, std::vector<Assertion>> assertions_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_FAKE_ASSERTION_VALIDATOR_H_
diff --git a/testing/fake_assertion_validator_test.cc b/testing/fake_assertion_validator_test.cc
new file mode 100644
index 0000000..2ae0b1c
--- /dev/null
+++ b/testing/fake_assertion_validator_test.cc
@@ -0,0 +1,74 @@
+// 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 "testing/fake_assertion_validator.h"
+
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/uri.h"
+#include "proto/assertion.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::EqualsProto;
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+
+TEST(FakeAssertionValidatorTest, TrackerIsPopulated) {
+  FakeAssertionValidator assertion_validator(
+      /*failures=*/{FailureStatusCode::kAssertionCborInvalid},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  TestValidationTracker t;
+  jumbf::SuperBox root_box;
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  Manifest manifest;
+  assertion_validator.ValidateClaimAssertions(uri_resolver,
+                                              /*redacted_assertion_paths=*/{},
+                                              manifest, t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kAssertionCborInvalid));
+  EXPECT_THAT(t.GetSuccesses(),
+              ContainsSuccess(SuccessStatusCode::kClaimSignatureValidated));
+}
+
+TEST(FakeAssertionValidatorTest, AssertionsArePopulated) {
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  absl::string_view manifest_label = "some_manifest_label";
+  TestValidationTracker t;
+  jumbf::SuperBox root_box;
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  Assertion assertion;
+  assertion.mutable_ingredient_v2()->set_description("some_description");
+  assertion_validator.SetOutputAssertions("/c2pa/some_manifest_label",
+                                          {assertion});
+  Manifest manifest;
+  manifest.set_label(manifest_label);
+  assertion_validator.ValidateClaimAssertions(
+      uri_resolver, /*redacted_assertion_paths=*/{}, manifest, t.tracker());
+  EXPECT_THAT(manifest.assertions(), ElementsAre(EqualsProto(assertion)));
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/testing/fake_claim_validator.h b/testing/fake_claim_validator.h
new file mode 100644
index 0000000..658cc94
--- /dev/null
+++ b/testing/fake_claim_validator.h
@@ -0,0 +1,126 @@
+// 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_TESTING_FAKE_CLAIM_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_FAKE_CLAIM_VALIDATOR_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "claim/validator.h"
+#include "constants/status_codes.h"
+#include "jumbf/box.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+class FakeClaimValidator : public ClaimValidator {
+ public:
+  // Constructs a `FakeClaimValidator` with a set of failure, informational and
+  // success codes to be recorded for every `Validate` call of the fake.
+  FakeClaimValidator(absl::Span<const FailureStatusCode> failures,
+                     absl::Span<const InformationalStatusCode> informationals,
+                     absl::Span<const SuccessStatusCode> successes)
+      : common_failures_(failures.begin(), failures.end()),
+        common_informationals_(informationals.begin(), informationals.end()),
+        common_successes_(successes.begin(), successes.end()) {}
+
+  std::optional<Claim> Validate(
+      const jumbf::SuperBox& manifest,
+      ValidationTracker& validation_tracker) const override {
+    // Record failures and successes.
+    for (const auto& failure : common_failures_) {
+      validation_tracker.RecordFailure(failure, {});
+    }
+    for (const auto& informational : common_informationals_) {
+      validation_tracker.RecordInformational(informational, {});
+    }
+    for (const auto& success : common_successes_) {
+      validation_tracker.RecordSuccess(success, {});
+    }
+    absl::string_view label = manifest.description.label.value_or("");
+    if (auto it = results_.find(label); it != results_.end()) {
+      if (auto failures = claim_failures_.find(label);
+          failures != claim_failures_.end()) {
+        for (const auto& failure : failures->second) {
+          validation_tracker.RecordFailure(failure, {});
+        }
+      }
+      if (auto informationals = claim_informationals_.find(label);
+          informationals != claim_informationals_.end()) {
+        for (const auto& informational : informationals->second) {
+          validation_tracker.RecordInformational(informational, {});
+        }
+      }
+      if (auto successes = claim_successes_.find(label);
+          successes != claim_successes_.end()) {
+        for (const auto& success : successes->second) {
+          validation_tracker.RecordSuccess(success, {});
+        }
+      }
+      return it->second;
+    }
+    return std::nullopt;
+  }
+
+  void SetClaimResult(absl::string_view label, const Claim& claim) {
+    results_[label] = claim;
+  }
+
+  // Specifies a list of failure codes that will be recorded when the claim with
+  // label provided in the parameter `label` is validated.
+  void SetClaimFailures(absl::string_view label,
+                        absl::Span<const FailureStatusCode> failures) {
+    claim_failures_[label] =
+        std::vector<FailureStatusCode>(failures.begin(), failures.end());
+  }
+
+  // Specifies a list of informational codes that will be recorded when the
+  // claim with label provided in the parameter `label` is validated.
+  void SetClaimInformationals(
+      absl::string_view label,
+      absl::Span<const InformationalStatusCode> informationals) {
+    claim_informationals_[label] = std::vector<InformationalStatusCode>(
+        informationals.begin(), informationals.end());
+  }
+
+  // Specifies a list of success codes that will be recorded when the claim with
+  // label provided in the parameter `label` is validated.
+  void SetClaimSuccesses(absl::string_view label,
+                         absl::Span<const SuccessStatusCode> failures) {
+    claim_successes_[label] =
+        std::vector<SuccessStatusCode>(failures.begin(), failures.end());
+  }
+
+ private:
+  absl::flat_hash_map<std::string, Claim> results_;
+  absl::flat_hash_map<std::string, std::vector<FailureStatusCode>>
+      claim_failures_;
+  absl::flat_hash_map<std::string, std::vector<InformationalStatusCode>>
+      claim_informationals_;
+  absl::flat_hash_map<std::string, std::vector<SuccessStatusCode>>
+      claim_successes_;
+  std::vector<FailureStatusCode> common_failures_;
+  std::vector<InformationalStatusCode> common_informationals_;
+  std::vector<SuccessStatusCode> common_successes_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_FAKE_CLAIM_VALIDATOR_H_
diff --git a/testing/fake_claim_validator_test.cc b/testing/fake_claim_validator_test.cc
new file mode 100644
index 0000000..719df74
--- /dev/null
+++ b/testing/fake_claim_validator_test.cc
@@ -0,0 +1,79 @@
+// 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 "testing/fake_claim_validator.h"
+
+#include "absl/types/span.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "proto/generator_info.pb.h"
+#include "proto/manifest.pb.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::EqualsProto;
+using ::testing::Optional;
+
+TEST(FakeClaimValidatorTest, TrackerIsPopulated) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{FailureStatusCode::kClaimCborInvalid},
+      /*informationals=*/
+      {InformationalStatusCode::kIngredientUnknownProvenance},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  TestValidationTracker t;
+  jumbf::SuperBox active_manifest;
+  claim_validator.Validate(active_manifest, t.tracker());
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimCborInvalid));
+  EXPECT_THAT(t.GetInformationals(),
+              ContainsInformational(
+                  InformationalStatusCode::kIngredientUnknownProvenance));
+  EXPECT_THAT(t.GetSuccesses(),
+              ContainsSuccess(SuccessStatusCode::kClaimSignatureValidated));
+}
+
+TEST(FakeClaimValidatorTest, ClaimIsPopulated) {
+  Claim claim;
+  claim.mutable_claim_generator_info()->set_name("some_name");
+  FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{},
+                                     /*successes=*/{});
+  claim_validator.SetClaimResult("some_label", claim);
+  claim_validator.SetClaimFailures("some_label",
+                                   {FailureStatusCode::kClaimCborInvalid});
+  claim_validator.SetClaimInformationals(
+      "some_label", {InformationalStatusCode::kIngredientUnknownProvenance});
+  claim_validator.SetClaimSuccesses(
+      "some_label", {SuccessStatusCode::kClaimSignatureValidated});
+  TestValidationTracker t;
+  jumbf::SuperBox active_manifest;
+  active_manifest.description.label = "some_label";
+  EXPECT_THAT(claim_validator.Validate(active_manifest, t.tracker()),
+              Optional(EqualsProto(claim)));
+  EXPECT_THAT(t.GetFailures(),
+              ContainsFailure(FailureStatusCode::kClaimCborInvalid));
+  EXPECT_THAT(t.GetInformationals(),
+              ContainsInformational(
+                  InformationalStatusCode::kIngredientUnknownProvenance));
+  EXPECT_THAT(t.GetSuccesses(),
+              ContainsSuccess(SuccessStatusCode::kClaimSignatureValidated));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/testing/jumbf_utils.cc b/testing/jumbf_utils.cc
new file mode 100644
index 0000000..d275786
--- /dev/null
+++ b/testing/jumbf_utils.cc
@@ -0,0 +1,233 @@
+// 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 "testing/jumbf_utils.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/log/absl_check.h"
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/status_matchers.h"  // IWYU pragma: keep
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "assertion/assertion_encoder.h"
+#include "constants/labels.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "jumbf/box.h"
+#include "jumbf/constants.h"
+#include "jumbf/parse.h"
+#include "jumbf/test_utils.h"
+#include "proto/assertion.pb.h"
+#include "utils/byte_readers.h"
+#include "utils/byte_writers.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+
+absl::StatusOr<absl::string_view> TrimJumbfHeader(absl::string_view raw_bytes) {
+  ABSL_ASSIGN_OR_RETURN(auto lbox, ConsumeUint<uint32_t>(&raw_bytes));
+  ABSL_RETURN_IF_ERROR(SkipBytes<uint32_t>(&raw_bytes));
+  if (lbox == 1) {
+    auto xlbox_status = SkipBytes<uint64_t>(&raw_bytes);
+    ABSL_RETURN_IF_ERROR(xlbox_status);
+  }
+  return raw_bytes;
+}
+
+std::string ComputeHashOrDie(absl::string_view algorithm,
+                             absl::string_view contents) {
+  auto hasher = CreateHasher(algorithm);
+  ABSL_CHECK_OK(hasher) << "Unsupported algorithm: " << algorithm;
+  (*hasher)->Update(contents);
+  return (*hasher)->Digest();
+}
+
+}  // namespace
+
+jumbf::SuperBox ParseSuperBoxOrDie(const std::string* bytes) {
+  absl::string_view bytes_view = *bytes;
+  auto box = jumbf::ConsumeSuperBox(&bytes_view, /*recursion_limit=*/-1);
+  ABSL_CHECK_OK(box);
+  ABSL_CHECK_EQ(bytes_view, "");
+  return *box;
+}
+
+jumbf::SuperBox CreateManifestStore(
+    absl::Span<const jumbf::SuperBox> manifests) {
+  jumbf::SuperBox box = {
+      .description =
+          {
+              .type_uuid = kManifestStoreUuid,
+              .label = kManifestStoreLabel,
+          },
+      .raw_bytes = "b64'testing'",
+  };
+  for (const auto& m : manifests) {
+    box.contents.push_back({.payload = m});
+  }
+  return box;
+}
+
+jumbf::SuperBox CreateStandardManifest(
+    absl::string_view label, absl::Span<const jumbf::SuperBox> boxes) {
+  jumbf::SuperBox box = {
+      .description =
+          {
+              .type_uuid = kStandardManifestUuid,
+              .label = label,
+          },
+      .raw_bytes = "b64'testing'",
+  };
+  for (const auto& b : boxes) {
+    box.contents.push_back({.payload = b});
+  }
+  return box;
+}
+
+jumbf::SuperBox CreateUpdateManifest(absl::string_view label,
+                                     absl::Span<const jumbf::SuperBox> boxes) {
+  jumbf::SuperBox box = {
+      .description =
+          {
+              .type_uuid = kUpdateManifestUuid,
+              .label = label,
+          },
+      .raw_bytes = "b64'testing'",
+  };
+  for (const auto& b : boxes) {
+    box.contents.push_back({.payload = b});
+  }
+  return box;
+}
+
+std::string EncodeClaimSignatureBox(absl::string_view cbor) {
+  return jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel,
+                               {jumbf::EncodeCborBox(cbor)});
+}
+
+std::string EncodeAssertionBoxOrDie(const Assertion& assertion,
+                                    const AssertionEncoder& encoder) {
+  auto assertion_cbor = encoder.ToCbor(assertion);
+  ABSL_CHECK_OK(assertion_cbor);
+  std::string assertion_cbor_box =
+      jumbf::EncodeCborBox(std::string(*assertion_cbor));
+  return jumbf::EncodeSuperBox(jumbf::kCborBoxTypeUuid, assertion.label(),
+                               {assertion_cbor_box}, /*requestable=*/true);
+}
+
+jumbf::SuperBox CreateAssertionStoreBox() {
+  return {
+      .description =
+          {
+              .type_uuid = kAssertionStoreUuid,
+              .label = kAssertionStoreLabel,
+          },
+      .raw_bytes = "b64'testing'",
+  };
+}
+
+jumbf::SuperBox CreateAssertionStoreBox(
+    absl::Span<const jumbf::SuperBox> assertions) {
+  auto assertion_store_box = CreateAssertionStoreBox();
+  for (const auto& assertion : assertions) {
+    assertion_store_box.contents.push_back({.payload = assertion});
+  }
+  return assertion_store_box;
+}
+
+jumbf::SuperBox CreateAssertionBoxFromCbor(absl::string_view label,
+                                           absl::string_view cbor) {
+  return {
+      .description = {.label = label},
+      .contents = {jumbf::ContentBox{.payload =
+                                         jumbf::CborBox{.payload = cbor}}},
+      .raw_bytes = "b64'testing'",
+  };
+}
+
+jumbf::SuperBox CreateAssertionBoxFromJson(absl::string_view label,
+                                           absl::string_view json) {
+  return {
+      .description = {.label = label},
+      .contents = {jumbf::ContentBox{.payload =
+                                         jumbf::JsonBox{.payload = json}}},
+      .raw_bytes = "b64'testing'",
+  };
+}
+
+jumbf::SuperBox CreateAssertionBoxFromEmbeddedFile(
+    absl::string_view label, absl::string_view media_type,
+    absl::string_view file_contents,
+    std::optional<absl::string_view> file_name) {
+  return {
+      .description = {.label = label},
+      .contents = {jumbf::ContentBox{.payload =
+                                         jumbf::EmbeddedFileDescriptionBox{
+                                             .media_type = media_type,
+                                             .file_name = file_name}},
+                   jumbf::ContentBox{
+                       .payload =
+                           jumbf::BinaryDataBox{.payload = file_contents}}},
+      .raw_bytes = "b64'testing'",
+  };
+}
+
+jumbf::SuperBox CreateActionsAssertionBoxFromCbor(absl::string_view cbor) {
+  return CreateAssertionBoxFromCbor(kActionsAssertionV2Label, cbor);
+}
+
+jumbf::SuperBox CreateBmffBasedHashAssertionBoxFromCbor(
+    absl::string_view cbor) {
+  return CreateAssertionBoxFromCbor(kBmffBasedHashAssertionV3Label, cbor);
+}
+
+jumbf::SuperBox CreateDataHashAssertionBoxFromCbor(absl::string_view cbor) {
+  return CreateAssertionBoxFromCbor(kDataHashAssertionLabel, cbor);
+}
+
+std::string GetHash(absl::string_view algorithm, const jumbf::SuperBox& box) {
+  auto raw_bytes = TrimJumbfHeader(box.raw_bytes);
+  ABSL_CHECK_OK(raw_bytes);
+  return ComputeHashOrDie(algorithm, *raw_bytes);
+}
+
+std::string CreateStartOfManifestStorePayload(
+    absl::Span<const uint8_t> prefix) {
+  std::vector<uint8_t> bytes;
+  bytes.reserve(prefix.size() + 40);
+  for (const auto& byte : prefix) {
+    bytes.push_back(byte);
+  }
+  WriteUint32NetworkOrder(10, &bytes);
+  WriteUint32NetworkOrder(jumbf::kSuperBoxType, &bytes);
+  WriteUint32NetworkOrder(10, &bytes);
+  WriteUint32NetworkOrder(jumbf::kDescriptionBoxType, &bytes);
+  WriteUint64NetworkOrder(0x6332'7061'0011'0010, &bytes);
+  WriteUint64NetworkOrder(0x8000'00AA'0038'9B71, &bytes);
+  bytes.push_back(0b0000'0011);
+  WriteUint32NetworkOrder('c2pa', &bytes);
+  bytes.push_back(0x00);
+  return std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size());
+}
+
+}  // namespace credentio
diff --git a/testing/jumbf_utils.h b/testing/jumbf_utils.h
new file mode 100644
index 0000000..e1912ff
--- /dev/null
+++ b/testing/jumbf_utils.h
@@ -0,0 +1,77 @@
+// 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_TESTING_JUMBF_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_JUMBF_UTILS_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "assertion/assertion_encoder.h"
+#include "jumbf/box.h"
+#include "proto/assertion.pb.h"
+
+namespace credentio {
+
+// Parses the given bytes as a JUMBF superbox. The underlying bytes must outlive
+// the returned SuperBox.
+jumbf::SuperBox ParseSuperBoxOrDie(const std::string* bytes);
+
+// NOTE: Helpers below returning jumbf::SuperBox set `raw_bytes` to a fixed
+// test value.
+
+jumbf::SuperBox CreateManifestStore(
+    absl::Span<const jumbf::SuperBox> manifests);
+jumbf::SuperBox CreateStandardManifest(absl::string_view label,
+                                       absl::Span<const jumbf::SuperBox> boxes);
+jumbf::SuperBox CreateUpdateManifest(absl::string_view label,
+                                     absl::Span<const jumbf::SuperBox> boxes);
+jumbf::SuperBox CreateAssertionStoreBox();
+jumbf::SuperBox CreateAssertionStoreBox(
+    absl::Span<const jumbf::SuperBox> assertions);
+jumbf::SuperBox CreateAssertionBoxFromCbor(absl::string_view label,
+                                           absl::string_view cbor);
+jumbf::SuperBox CreateAssertionBoxFromEmbeddedFile(
+    absl::string_view label, absl::string_view media_type,
+    absl::string_view file_contents,
+    std::optional<absl::string_view> file_name = std::nullopt);
+jumbf::SuperBox CreateActionsAssertionBoxFromCbor(absl::string_view cbor);
+jumbf::SuperBox CreateBmffBasedHashAssertionBoxFromCbor(absl::string_view cbor);
+jumbf::SuperBox CreateDataHashAssertionBoxFromCbor(absl::string_view cbor);
+jumbf::SuperBox CreateAssertionBoxFromJson(absl::string_view label,
+                                           absl::string_view json);
+
+// Returns a serialized claim signature JUMBF box.
+std::string EncodeClaimSignatureBox(absl::string_view cbor);
+
+// Returns a serialized assertion JUMBF box.
+std::string EncodeAssertionBoxOrDie(const Assertion& assertion,
+                                    const AssertionEncoder& encoder);
+
+// Returns the hash of the given box per
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hashed_uris.
+std::string GetHash(absl::string_view algorithm, const jumbf::SuperBox& box);
+
+// This creates the start of a manifest store payload, just enough to pass
+// MightBeC2paManifestStore(...).
+std::string CreateStartOfManifestStorePayload(
+    absl::Span<const uint8_t> prefix = {});
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_JUMBF_UTILS_H_
diff --git a/testing/mock_assertion_validator.h b/testing/mock_assertion_validator.h
new file mode 100644
index 0000000..665835f
--- /dev/null
+++ b/testing/mock_assertion_validator.h
@@ -0,0 +1,40 @@
+// 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_TESTING_MOCK_ASSERTION_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_MOCK_ASSERTION_VALIDATOR_H_
+
+#include <string>
+
+#include "absl/container/flat_hash_set.h"
+#include "assertion/validator.h"
+#include "gmock/gmock.h"
+#include "jumbf/uri.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+class MockAssertionValidator : public AssertionValidator {
+ public:
+  MOCK_METHOD(void, ValidateClaimAssertions,
+              (const jumbf::UriResolver& uri_resolver,
+               const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+               Manifest& manifest, ValidationTracker& validation_tracker),
+              (const, override));
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_MOCK_ASSERTION_VALIDATOR_H_
diff --git a/testing/mock_cose_verifier.h b/testing/mock_cose_verifier.h
new file mode 100644
index 0000000..a3e4762
--- /dev/null
+++ b/testing/mock_cose_verifier.h
@@ -0,0 +1,37 @@
+// 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_TESTING_MOCK_COSE_VERIFIER_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_MOCK_COSE_VERIFIER_H_
+
+#include <optional>
+
+#include "absl/strings/string_view.h"
+#include "cose/verifier.h"
+#include "gmock/gmock.h"
+#include "proto/cose_verification_result.pb.h"
+
+namespace credentio {
+
+class MockCoseVerifier : public CoseVerifier {
+ public:
+  MOCK_METHOD(CoseVerificationResult, Verify,
+              (absl::string_view signature, absl::string_view data),
+              (const, override));
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_MOCK_COSE_VERIFIER_H_
diff --git a/testing/pem.cc b/testing/pem.cc
new file mode 100644
index 0000000..9844e16
--- /dev/null
+++ b/testing/pem.cc
@@ -0,0 +1,101 @@
+// 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 "testing/pem.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "openssl/bio.h"
+#include "openssl/ec.h"
+#include "openssl/ec_key.h"
+#include "openssl/evp.h"
+#include "openssl/mem.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/pem.h"
+#include "openssl/x509.h"
+#include "proto/common.pb.h"
+#include "tink/cleartext_keyset_handle.h"
+#include "tink/keyset_handle.h"
+#include "tink/public_key_sign.h"
+#include "tink/signature/config_v0.h"
+#include "tink/signature/signature_pem_keyset_reader.h"
+
+namespace credentio {
+
+using ::crypto::tink::PemKeyParams;
+using ::crypto::tink::PublicKeySign;
+using ::crypto::tink::SignaturePemKeysetReaderBuilder;
+
+absl::StatusOr<std::unique_ptr<crypto::tink::PublicKeySign>> LoadTestKeyFromPem(
+    absl::string_view pem, const PemKeyParams& key_params) {
+  crypto::tink::SignaturePemKeysetReaderBuilder builder(
+      SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_SIGN);
+  builder.Add({
+      .serialized_key = std::string(pem),
+      .parameters = key_params,
+  });
+  ABSL_ASSIGN_OR_RETURN(auto reader, builder.Build());
+  ABSL_ASSIGN_OR_RETURN(auto handle, crypto::tink::CleartextKeysetHandle::Read(
+                                         std::move(reader)));
+  return handle->GetPrimitive<crypto::tink::PublicKeySign>(
+      crypto::tink::ConfigSignatureV0());
+}
+
+absl::StatusOr<std::string> LoadOcspResponseFromPem(absl::string_view pem) {
+  bssl::UniquePtr<BIO> bio(BIO_new_mem_buf(pem.data(), pem.size()));
+  if (bio == nullptr) {
+    return absl::InternalError("Failed to create BIO.");
+  }
+
+  std::string response;
+  bool found = false;
+  while (true) {
+    char* name = nullptr;
+    char* header = nullptr;
+    unsigned char* data = nullptr;
+    int64_t len = 0;
+    if (!PEM_read_bio(bio.get(), &name, &header, &data, &len)) {
+      break;
+    }
+    bssl::UniquePtr<char> name_ptr(name);
+    bssl::UniquePtr<char> header_ptr(header);
+    bssl::UniquePtr<unsigned char> data_ptr(data);
+
+    if (absl::string_view(name) == "OCSP RESPONSE") {
+      if (found) {
+        return absl::InvalidArgumentError(
+            "Multiple OCSP RESPONSE blocks found; expected only one.");
+      }
+      response = std::string(reinterpret_cast<char*>(data), len);
+      found = true;
+    }
+  }
+
+  if (!found) {
+    return absl::InvalidArgumentError("No OCSP RESPONSE block found.");
+  }
+
+  return response;
+}
+
+}  // namespace credentio
diff --git a/testing/pem.h b/testing/pem.h
new file mode 100644
index 0000000..234fe7d
--- /dev/null
+++ b/testing/pem.h
@@ -0,0 +1,40 @@
+// 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_TESTING_PEM_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_PEM_H_
+
+#include <memory>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "tink/public_key_sign.h"
+#include "tink/signature/signature_pem_keyset_reader.h"
+
+namespace credentio {
+
+// Loads a TEST private key from an unencrypted PEM string, with explicit key
+// parameters.
+absl::StatusOr<std::unique_ptr<crypto::tink::PublicKeySign>> LoadTestKeyFromPem(
+    absl::string_view pem, const crypto::tink::PemKeyParams& key_params);
+
+// Returns a single DER-encoded OCSP response loaded from a PEM string with tag
+// "OCSP RESPONSE". Returns an error if multiple blocks are present.
+absl::StatusOr<std::string> LoadOcspResponseFromPem(absl::string_view pem);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_PEM_H_
diff --git a/testing/pem_test.cc b/testing/pem_test.cc
new file mode 100644
index 0000000..7eaa0c9
--- /dev/null
+++ b/testing/pem_test.cc
@@ -0,0 +1,346 @@
+// 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 "testing/pem.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "tink/signature/signature_pem_keyset_reader.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::crypto::tink::PemAlgorithm;
+using ::crypto::tink::PemKeyType;
+using ::google::crypto::tink::HashType;
+using ::testing::HasSubstr;
+
+constexpr absl::string_view kPemKeyP224 = R"(-----BEGIN PRIVATE KEY-----
+MHgCAQAwEAYHKoZIzj0CAQYFK4EEACEEYTBfAgEBBBxaHY9SBldD/sUFJQyS4bOU
+2Hf8wXBCEzfEdZ6DoTwDOgAEF3QCwJRkgQxaYZ0cqZTb0FAYQ2alOv3lWlmlNLS1
+80rBbqgzt26PS8ysBhJy8zpTdmkKxdv2SpQ=
+-----END PRIVATE KEY-----)";
+
+constexpr absl::string_view kPemKeyP256 = R"(-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYUrTw5gDocgm/uqt
+9Y0uHuYZ+qUORlv4tsVvsC+RafyhRANCAARhNXddv5HV8eddKtCGlJ2rFUjyDZWK
+iVqaBIXOc3nsxuyQuprla45ifAN/nO9vsAWP6OjOVYcgS/MLvjpQHOrm
+-----END PRIVATE KEY-----)";
+
+constexpr absl::string_view kPemKeyP384 = R"(-----BEGIN PRIVATE KEY-----
+MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDcgueympOc5A87sQYu
+QQqtX50rK0OMz9aowmNXDnEXm4U62eevBJVSUATFyxkZ0qChZANiAASe9I/03xH0
+SB3lclDxJpnHk8tsq1aqZbBl0G5giE3h/KpRd1303+spJAv1UZZhbduuWzycEo1s
+EtjDR7qk4+cSrE2ii9ltavHCQJQUsfYkmcesZJh9KpHi8LIhyY50e40=
+-----END PRIVATE KEY-----)";
+
+constexpr absl::string_view kPemKeyP521 = R"(-----BEGIN PRIVATE KEY-----
+MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIAr8855XdpHWs9YBRm
+oiy+QZeG5KB14Kl2Rr4DRLgt3258xfm8fuDRHluuJEHZQibrn86u50KkMNVT1qgD
+zlqLkwChgYkDgYYABAG/tAXbStwpjnpW6Mla6JboL4VPNfAM+YlgtwTj63j5TN0N
+n6krb6JG1yM3fxzEf0BL58wbV/quMxozEQfGxtmyzQEYlJmaWOaXangUvVuY3QAf
+v9PLp4egCYvH2iooca4rv64wEhVErXSF8Zre976ITX5p4LUX4Q3DECdnbom2yU2q
+qQ==
+-----END PRIVATE KEY-----)";
+
+constexpr absl::string_view kPemKeyEd25519 = R"(-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIKdWv26hzGxk0up0J6859Q+ELEu/FBMg/PAn7dlxACK8
+-----END PRIVATE KEY-----)";
+
+constexpr absl::string_view kPemKeyRsa1024 = R"(-----BEGIN PRIVATE KEY-----
+MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBALFa2Jbkosxnt8JT
+bry40Mk0kUY/VdFI4Y2RzyzwT6Zx8xY4BqAF8fpjmYjwqezvnA3VwgLDso0TuyVE
+OLWcF3rdrxnHSLnuao4CrizKgJJyVOJRpkBtwpGoz4tMzl2lyLv7TI0wL5t5oH8A
+7iggeJC0ElTOITVBwcpAlvsZ9pbzAgMBAAECgYABK6DPSh367FpNmYBbFXsnQMYO
+qR9XXLxEU2iDGujjp8/bXy5IAxIIPX0HZtMwQZqNitIXc5QrkyxCgDNBm5CaF6bR
+CVJW/TlJ4W+l1g5cG5SnahNfVBlEDf0hlIkf3sFZ5XwL77tq7Ft6JQ7caqWTgaw7
+plfL1GpAywUuuStCkQJBAOggRSVjxeLWAPaX3J/PuqxrtjgyjtkkEK8upoz41k+5
+24SCSbBtDcMBLfR2GrcpVKWi/S5rMONVXc9giVQqa0MCQQDDmHxbPBNg8k53zCne
+N/8rkGOaUWt05XEjiz+CJ5BJM3w7og+Ky+6fmM3rnVuJ3mKtz+2goX55AqUagz+M
+ZHKRAkACrmS+EtH1Vc17C7QDsI2SckgPjB30SiNbMqm/1Rv8ncP4WtzpCclv6DKU
+sUyLsxGEPEa6Y4iUB6dmvtG3RCorAkALfw7wHdvVuug5ylo7oXbyDI6OJ3L1b/by
+a+DTuZeVdd07dA8NzWa9IrIXwyv6MoQCS1JgOp3AZxNlN4moqs7BAkBdYa/HFdWv
+rbAP9L4Sm0BdSH3ZC6Vvn/u3uXAe7teLoAE4lZsbePmOBAGZW2vmlMEK3cnLk/mt
+nBJ6QgwSnpno
+-----END PRIVATE KEY-----)";
+
+constexpr absl::string_view kPemKeyRsa2048 = R"(-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4KLDzI35zPSEW
+Tbt2ZdmlKbvghqmJHWchoiptOPzfkCGdn4gzX5VZZHS+knS5fY/iI6a+o1qXdPkp
+40/p+yPyyQHbFpfid+wiRgCClZHYtPik6XI8OgVunBGQGBF9EACgEwqIB1Hw4yop
+VS9EZigeEXHM7GGuwhG/BpAsRMBbdxKjwtnR0iz8KDeb0Mz6UNi90CtxkBS3wqBv
+en6/nQZYJ2WfNt+uArQw8x8uSnuBHqhjTbOi4BLbi+OgfPRfXR0u6kWQWYOqu43D
+Kbv3s1N0mjqITFavoYvNLRe6krbbA7s08H1OZ0vZXIdy00loYqwcUNLWzSY1P3pj
+D5+OjifnAgMBAAECggEAJmCoe4rei7JmBkldsy9He4Fme0Rk6UOc3ef3EnrpS8e0
+Ea6Qyi/1pkLmh15q/8rYZMaMNbdCJkUEtcN+wGKLW8mi3t7m+RWHup6+9XepBLG2
+DVvu5/kpb6MLj+1MG7Vtg+5j5/QqkdotS57KcmUdoCJ/hjaOpsYfv6DlA6yyL30W
+iepLSeEJrwmIeSVZZ2U21jfBPfl8bWR21KhSpu2H6uCbbn3mLNJR2Vd0RFvuNxYe
+HvbaGgRqmhnrv9XzW31UrF4yfVVMeRoeerzjiFHSUzLCAq002L8C9udeyVNqab6U
+6kSwBVAKpowmhQ7yWkzPN9kmAlsOuhxSX0xr1gTZMQKBgQDwOlH26CaVkHuUknVu
+Le3PmHSG8zVCTGBXeovje+c9OYPXC4KbtcbdRGCtMUTkglCHuZ381N1K7uXC7eJ4
+OaCFMrN+ChQnWcoN3QqXGDuzFwPjoIESVHbiZ2X5VkwpYlQ4qnV2JvqghmKu87Wz
+wDBfLmwE0/gTCMy+s2Cra9isMQKBgQDEP/tT6zXng3TwI0YynRCQ3+FuO+SE7tIj
+JbOoyKaFdF2KkFe9mN9QiLJkkiqW8rpGBMZT6/bBF394b1EujBYYNgriCadP/mHo
+U3M8NKTfViwfAHnlzS5vSrk5w5iJHqgrJgcu0R/pf0qJ3qfhrd96gyz7dNylYp5E
+XgcfzS9HlwKBgQCbEQpXlwNRCDK9dMzYkk0v8/lEIzn90c9eqHwkG5bornesGIH2
+gSAdNEYzhdoypuvUsLyKt90gwZUOfv/xG0HCuTej5F2eCRekW0q6vanLsM+R38lx
+S5RyEMyCZnJDRHWZHm80FXUyqYyJ4kcoAKc4yX5wn2NC5itD7gY4oST8AQKBgGvk
+OQoz39i93sY+vlMJIphjXQ47gzDKE2EbUyBR36qzBNIu9O3ZY7xkyp6b9Sozm9Du
+wxJUQb56aqwNlvYeMe9xlp4abI9/7NfLOSk6UAIw3+TTYDDEkujAjgCB1hRcdFv0
+djJ46jB2uMX/URCzhI9YGpzL7JpIE3bGsuf66ClVAoGAfIfimDz5JdUyqDNN3Sqi
+MUBPmZywDxymF9gl6iSB2scenRYmFz9b0mGrs1+45lPDKxlTmd4E/YrTwDh1VSCs
+cQLS9rLBwTyXY+j17DUfYfRUczaF0aZrrpjcT9jkF0AKVTBRaw3+jtd3GV6OtopW
+uPwS26qT7r9KF5Df73qDGkI=
+-----END PRIVATE KEY-----)";
+
+constexpr absl::string_view kPemKeyRsa3072 = R"(-----BEGIN PRIVATE KEY-----
+MIIG/gIBADANBgkqhkiG9w0BAQEFAASCBugwggbkAgEAAoIBgQCIlb4z+JN2A114
+bbXyS0R/EtvnmQUtf0nfU2aGB2Ej3SJfNIIAAMophWHeI3tqRYntboU+jKn31/JO
+O17NhGBbzc4F5T8wQ6JbIxf3XJGqQLbTQiSjpdRQife6gt3fXrgCqUdYpCz+HAAB
+0RbjS0as04sveDrTDZ4EwjZHFYsc/fk4cR6jKYk98hqe39fNeQjkA4/MlDOR7yBa
+IomPXh7QlnjEHNfsXWW5pQQTyyY6wOSqCdzMTd7sk6R5Vr/iGtVENxypdtTpA3ld
+qC2zNolb/0+5eAw96nGpXuvUFGTnFChSpG3NQ/0GvBz5KcrC+/s6ImOkRF052leK
+FYxxGacS8VeJ0O0xvQwKBjH6Z0KS+OR2vZOeOMSWbea/EP6SGzr7Ntlwjx9oyZG7
+Wf7EUpSSbknF36j+cn2YBPl8MWEjQZQ7OQnfcryWNzri4/kbkpgk4mwgB7MkgTI4
+qO6FoJsMz55F++U88uq0MDz7Qec8WKinryhEQUZCxnSa4MQznI8CAwEAAQKCAYAf
+/Ron5975c75Pdzp2vcQUEuGn80GwLzCo+kp3t4C7uo3TSGPuBkFSyidqtFqyQAkW
+r5zLUnx4TH42blc8wPUkayYGYXOUJTMAL07soySGgFORVNtp7Gu2VSnM59t49iEY
+lnbcPs8+0GX8BvFaWIX78iuV+Y2lYnoQ2T+HFHeZWa4l1vB6/D5lYK91aC2NtPwt
+Etx56HfPhPbJxW78QenrpmIwZCT5+cHaYdh7nkec2RJ09Ixe2RoNxGJv/K5CEVBr
+64m8p9PZ57c0P+n96ovxYpiyXj6jhmxLAktjoA+giiq6C+mtdMQLaDrSR6XLjG8L
+F6aYxjGHCCQdzEbDuBKfraovXJeQNNBh19tu93N8VINh2ANQXwJHSpktCvefjTDy
+vxt5xSOnbhYVD1bBPDyd1i2rRl25VmHjHHFkk7d9NvTecXCbdZlfyFA+BUKjc8zN
+j6AyyJFo8+SIXl707/d3mB86JKDeJfvD5iaBzlXwr5CsHthRUZ4lrrMVt978Y8EC
+gcEAu6ew8PA+jjOVO7ddFsUCueL1XQ0btx/lqOiQ+1mnjns6iisvSQIvSGHMir7o
+h1c0MEbDM2/iJbk8rU8O3o6yz65oqLNt2mH8xKKEqBslBeUqZeiVVXB85PXZ0Pvt
+ZCuCEDjDO3OWvccDL/n3AAy0ELwlBJCHSmiBOVCtH0GV3Z2Gp7Jf1ZwHtJwMDc9z
+T7JV6pABq7kgWQIorBF9S2Q9a4gMNFoGdp+bgGR5bPIPXYytv1/60chOTFmswFF1
+ST8vAoHBALpUc3CU2xM30FGn/faMjDA4XyX6zyPJMu5KHdZrmG0h1tAm20GNSGDl
+4IJkAslO1uQei+b8GqfkAjqnF4yob6wD1xhgC9ylFhChhEdCDPnnrvOr0+5P6paV
+x9pqc1MMpsGHUI0gg4E8AY7FQYPggcET+MymZOgGQ0MyHQMalxo8U82hEMwKfxYd
+m/i7DC5Fwq4yoWMIIuw7Nsb346d/qD2huLEuq73sAOXxDO0EAoRj6pFSWxgCQlgd
+Nf4JM1MgoQKBwQCXtt7GHOXmX+viSL/5HbSzhruqzGNA7YfmxIgavoRKHHTaqklj
+dGG32mHdNrWWXjlyYnOEzYr/xsA4ufcilioY4D/7TH2fFqOecmpf4vpOxtU9aQn6
+9qPzFJVScaue1dIkvU+SbEROEG0SR7ybjKmz8fjXtOW8kbsQFJqy73pzntz0DbGP
+aMOOEK9qJvNqVptBtcW0CUsOy0EIxGcRIezKWEbNuI8LLsUwy4qkOxuTHZ6HX9jP
+7GrpLYMORY1Wd78CgcEAmMUHBSY23HHljh2jftLLWfnOIVCbJucep/sCrbv39HpL
+KszA9eUFk9TGF4IyOqU2UUCuSgsnO8IZBcfb6gprzOg1IhoVf6mnBqm2z8na48bO
+TaDzGTBS0XYqruyac1wrGP99zsf6qintyQX36k1XdVuPoRNIJmiEG4LqAJJ7kxol
+R7IoGHL+sb5DFFMf8IxnyvVeFtkj8PW3urih4cIryaRfzn3Kd+QY9G6Hv4iLG8rk
+FpQkdfHcPWxtyUP33c8hAoHAbS7AWBzBcjj2t4fDdT4WlNl9i0BWRkGRMaRzEZx3
+E/S4aCbLw+HMf+mE2YrTAdEqWw2ax8UBXScb3pgpYoNYsQQ6+2FQJNQF/ijHQP4l
+sAsuTISiAHYwhZoDG67uwpsdmVNhXJOp2Et+1xDuzVek1iaW+mtjJhwj208C9Lmj
+YDghz5pVeMk3WBJFGiXQn1QhjZzTVog6rSoLj0kEgT9whx9C65ZxbEc1GKSAYun/
+dVMvgoGxsF6YKmXFRRLcNcRS
+-----END PRIVATE KEY-----)";
+
+constexpr absl::string_view kPemKeyRsa4096 = R"(-----BEGIN PRIVATE KEY-----
+MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQC2NVmEcVNKJcLB
+K9A/JNzQDqIJoxbWmF1gi6QK9bD208oE5PzGeCp/MCpr1rlE+WNfMfnEHUkWB7R2
+Hp+ZsHlXYpfpgXQFNCa6cxj2R2D4eZrot9neoaqq4TWKQpqLc+rL7uSqY3lLspKl
+F7I11vHP1JFOh+PTtmSrFq46fALZBzl08VznEk4nq3XXr+H0T4++KukXg+yifCrU
+/kZjtm99ibGvxqi+bADGTvgjfa9eissC5U3Um/Dpwbgjw09hEQ1KMi8mhL2EC0K8
+5mN43Un+M8DkFU/kTJofkUH42FSJPrjlMjaLtU47TgIYmSvqJNj4V157Bl4gDmWW
+1BOc4sZE1qC4WmKxk7exooSC0XwQnEFcMkHhvJjuemTKh3qPWuu8J3wu7+wR+Dmf
+QRO+9zk3cbjkTaDNNkCtYrVQrQiVZ8tcmrmOxac7dKEKpQTzCutjv7bmqR2XYSTR
+nUyJXSEFgsufaHB5YPBbPoupO5YbBPmf4iJthvbgwm+KX8sfECLkbDMaVjs2ftpE
+m3m+uStsFSX2IWKmkq8dZPtjqZRz1G3FukTzqeSxvqE1En6EpwjU06T2u5bRXC/b
+eURu6AxnIToOkPKTELqRNX2Y9Dovbqh2oPYSd0QW/IL4liaOHL9bZ+t1PVXP3OlS
+jKxfelege/2GdFgIkwoSL7BYhpwSsQIDAQABAoICAAeB4LeWhMBCGIllgP3NCsUO
+yUnc3KDIY9aJwl9KOrmcxc7YUXrq8hNN/H13eig3n0rJkBkuvLdp51tTjS9jqEg7
+sXcnRIy/VpYpjl59V+c8+W87vgAQiKvkPhS9hEkZJFQ1Oloz4YhHKggf1xGlUs7F
+mxwReMddFT5AX7rcp7Bh/hHNrQ5/QFWdmKwjnMt4aLuE4Jg9sQcL1ybx+Bv9JRBW
+10mfBY5c/FYvZEVwFQTv8ofiw0duimlTOD9O+YsODQ5gzXUIlJdncF9AndoZwgX1
+g23SW/mT12jUDxQN88O44BzfagRtGrVSVD9Htlm+HWt0DBpFC6VF+pURlWJKLLGR
+Gd7GrYf+2S0Lxbg7XAJBT4z8Zd5iaKttmW4zguTUYW/Wf8B+kvZ2hb+S+qQ/5yhv
+HHJ9kZjXN6XQLGH4ML20GoXk/TkL7B+fUlhFIbixKI0i/8n4OBo8lTycMtMQyflL
++vYYC/ly4fLuN9PjfT/9DxmEl5fokw6eNq0aT01ijOJ2pFzg6aBcEnFUwKAY5Moi
+jQ1/7ab7C0f6w3fOh+Dz9PV3Q1yq4G17MvtbpriWMdTlzjtlKiIK0B6EO5BDBvuz
+gGHgWBFCTi2lYX6vygiHZxxdOFPQCRlvNWrUbrqcH0L+g6mXdwFaAq7zWHu50W0T
+BDPKflh/02rwnvdjsM59AoIBAQDdJICtBjj9lxbohJToMtue9GE2bjjs/mlfBOld
+Z/CjkjG1qAp3mxJ0SS0sBoU54DF4Cu34THL/TzzdomK9Gd21+Ed5zrC5yHCaVSRY
+PbJFsQo5KujFso5zYt2ZieDvYzUyYHiocOz4LQ80skOzEQ/ti0B8ebYT4B9C9oTH
+zBp+AvyRswBodRosHxbddWaOVweiPdYJzKe6CRWQP5ceeIEqAhiUKM7P1kOZhlQN
+0wISoMkE4/jlUgc4IQ1hp62gJ7OYVnrsKCvoEe3Wsdk0PBqcUfKgtjDLKDlCK9KC
+p4kQtVIQoOKFNDT1C2Npy2x1KS0B2NRsPVMXdmRgX1lxDUx9AoIBAQDS7ch3FE6l
+bBqNv120K/jgNrJR2nVsaFv7ptA5KV1kfNvprb8jikf46OxQJIzDBWt8sGTUB4td
+jdG2hTpwU8p1Dlw1UDYtsy1opp7LcKQFILleo/sLHH4FM3D/tq/0exX6KwIPLbIb
+sf0q74wi/jUh5hr58ub6l/tgBEMNlp4xuo6FHs38nEyPhURVwhbYGTH0v+AZj/vj
+gLLPN1MY05zJelrcIVFE/RINdFKzppTQbSgqUv9dGprimkfoiJXmAbC3N65nq3Z1
+Vs0slS6HEHedlr6/1ZnSg33x0OZVVVkzleNYUb6gR0Kv7ZSwQ7Tq2742o5BtlMvL
+FGBrtTSWW1lFAoIBACKahqQMeDh+PvBtZpua8bDCzr9kqfWzratXqmiaThZaVvr0
+64ZhtpaJqM7dylUPFs6Q70HOPKRkssJ/mXG7rWy/B/gwdojrq2sK7Z/9L3smQ9JE
+O55L3WyCyOgwogjzfix+2gKJgRsoedumFV/cyKBEDPgtV2dux5WD260j6/VR5FGs
+L+EoicrjpoFK0JxvU9t4T5jC9G/w4fXrbWswMg3vvAbbB9mPzo4kbdyn6wgdLrdc
+MOM5XbXTvjNMuKd57ulkhuGTO6OZwpy4waMh0IwraorpV2NCzQrkrpERRCpgJRJD
+Co8LZ0n82TaBxVvj8I38bujtA6aFORm3cdh120UCggEAcm0eoEL6+1CvAxC6o8Br
+Di9+A1uw8fOXWui+8xOWn6oAnz9rfrtImia59Cwv1Kh/DDChMkk5eLIz/4E9F6zN
+Gz2EFkQCcD5QtwIVCSZ9RG7hQQ9SZE+7qdSjS4Xu5IFVhdaJEnTmt5GSVxWG/4DK
+kC5Fmi0E0xNaAletjJSWE0IyiNMSSiT8f6t7S860F91rNWxWBWgP7PZninRn9wLt
+fnuLCPpmHTfw8C2Xo/ANsRhWfsdx0y9YsO+jbOmT8CgglvR0/7aL19IUEkiH6iur
+yGj76BPXk9VToLiEC+z2LD73C3WlJUwjSE9pp0nDvtkg0oFfG6Np/yx5tlSiR6Mc
+xQKCAQAJn4pRIayA2JIOob4OblB7rPijcKdvbYgeCx6op/v2mXOdziPzRrOVUvYZ
+WStKzZzYxwat0DjCc96xThMVSaBGl8m16l6lFl2wDJI6ir7nLq+uQcMHrSi24zuS
+6gIjbZCoOGqeQXAbAGsFVsme0GdVZGt96sooTOycy8JQ3x7JkQdv1VASQdothWHS
+sRPe6OLdCHZBpB9glp4TLXZ1/U4JEQhueiiTxRLAOSITpAKt1kgRmJnjX1tSOq2k
+D0478tD3di3bxvGxzM/I5XHRiIikPzsemA4nGDhkSqumceRtdJPCiH68puUiBorf
+C4Yc1VEuRRkvHG4duH9NL6soWRHT
+-----END PRIVATE KEY-----)";
+
+TEST(LoadTestKeyFromPemTest, FailsOnInvalidPem) {
+  EXPECT_THAT(
+      LoadTestKeyFromPem("not pem data", {.key_type = PemKeyType::PEM_EC,
+                                          .algorithm = PemAlgorithm::ECDSA_IEEE,
+                                          .key_size_in_bits = 256,
+                                          .hash_type = HashType::SHA256}),
+      StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(LoadTestKeyFromPemTest, FailsOnP224) {
+  // P224 is not supported by Tink.
+  EXPECT_THAT(
+      LoadTestKeyFromPem(kPemKeyP224, {.key_type = PemKeyType::PEM_EC,
+                                       .algorithm = PemAlgorithm::ECDSA_IEEE,
+                                       .key_size_in_bits = 224,
+                                       .hash_type = HashType::SHA256}),
+      StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(LoadTestKeyFromPemTest, P256) {
+  auto signer =
+      LoadTestKeyFromPem(kPemKeyP256, {.key_type = PemKeyType::PEM_EC,
+                                       .algorithm = PemAlgorithm::ECDSA_IEEE,
+                                       .key_size_in_bits = 256,
+                                       .hash_type = HashType::SHA256});
+  ABSL_ASSERT_OK(signer);
+  EXPECT_THAT((*signer)->Sign("data to sign"), IsOk());
+}
+
+TEST(LoadTestKeyFromPemTest, P384) {
+  auto signer =
+      LoadTestKeyFromPem(kPemKeyP384, {.key_type = PemKeyType::PEM_EC,
+                                       .algorithm = PemAlgorithm::ECDSA_IEEE,
+                                       .key_size_in_bits = 384,
+                                       .hash_type = HashType::SHA384});
+  ABSL_ASSERT_OK(signer);
+  EXPECT_THAT((*signer)->Sign("data to sign"), IsOk());
+}
+
+TEST(LoadTestKeyFromPemTest, P521) {
+  auto signer =
+      LoadTestKeyFromPem(kPemKeyP521, {.key_type = PemKeyType::PEM_EC,
+                                       .algorithm = PemAlgorithm::ECDSA_IEEE,
+                                       .key_size_in_bits = 521,
+                                       .hash_type = HashType::SHA512});
+  ABSL_ASSERT_OK(signer);
+  EXPECT_THAT((*signer)->Sign("data to sign"), IsOk());
+}
+
+TEST(LoadTestKeyFromPemTest, FailsOnRSA1024) {
+  // RSA1024 not supported by Tink.
+  EXPECT_THAT(LoadTestKeyFromPem(kPemKeyRsa1024,
+                                 {
+                                     .key_type = PemKeyType::PEM_RSA,
+                                     .algorithm = PemAlgorithm::RSASSA_PSS,
+                                     .key_size_in_bits = 1024,
+                                     .hash_type = HashType::SHA256,
+                                 }),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(LoadTestKeyFromPemTest, RSA2048) {
+  auto signer = LoadTestKeyFromPem(kPemKeyRsa2048,
+                                   {
+                                       .key_type = PemKeyType::PEM_RSA,
+                                       .algorithm = PemAlgorithm::RSASSA_PSS,
+                                       .key_size_in_bits = 2048,
+                                       .hash_type = HashType::SHA256,
+                                   });
+  ABSL_ASSERT_OK(signer);
+  EXPECT_THAT((*signer)->Sign("data to sign"), IsOk());
+}
+
+TEST(LoadTestKeyFromPemTest, RSA3072) {
+  auto signer = LoadTestKeyFromPem(kPemKeyRsa3072,
+                                   {
+                                       .key_type = PemKeyType::PEM_RSA,
+                                       .algorithm = PemAlgorithm::RSASSA_PSS,
+                                       .key_size_in_bits = 3072,
+                                       .hash_type = HashType::SHA256,
+                                   });
+  ABSL_ASSERT_OK(signer);
+  EXPECT_THAT((*signer)->Sign("data to sign"), IsOk());
+}
+
+TEST(LoadTestKeyFromPemTest, RSA4096) {
+  auto signer = LoadTestKeyFromPem(kPemKeyRsa4096,
+                                   {
+                                       .key_type = PemKeyType::PEM_RSA,
+                                       .algorithm = PemAlgorithm::RSASSA_PSS,
+                                       .key_size_in_bits = 4096,
+                                       .hash_type = HashType::SHA256,
+                                   });
+  ABSL_ASSERT_OK(signer);
+  EXPECT_THAT((*signer)->Sign("data to sign"), IsOk());
+}
+
+TEST(LoadTestKeyFromPemTest, WrongKeySize_Fails) {
+  EXPECT_THAT(LoadTestKeyFromPem(kPemKeyRsa4096,
+                                 {
+                                     .key_type = PemKeyType::PEM_RSA,
+                                     .algorithm = PemAlgorithm::RSASSA_PSS,
+                                     .key_size_in_bits = 3072,
+                                     .hash_type = HashType::SHA256,
+                                 }),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(LoadOcspResponseFromPemTest, FailsOnInvalidPem) {
+  EXPECT_THAT(LoadOcspResponseFromPem("not pem data"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(LoadOcspResponseFromPemTest, Succeeds) {
+  constexpr absl::string_view kPemOcsp = R"(-----BEGIN OCSP RESPONSE-----
+ZmFrZSBvY3NwIHJlc3BvbnNl
+-----END OCSP RESPONSE-----)";
+  auto der = LoadOcspResponseFromPem(kPemOcsp);
+  ABSL_ASSERT_OK(der);
+  EXPECT_EQ(*der, "fake ocsp response");
+}
+
+TEST(LoadOcspResponseFromPemTest, FailsOnMultipleBlocks) {
+  constexpr absl::string_view kPemOcspMultiple =
+      R"(-----BEGIN OCSP RESPONSE-----
+ZmFrZSBvY3NwIHJlc3BvbnNlIDE=
+-----END OCSP RESPONSE-----
+-----BEGIN OCSP RESPONSE-----
+ZmFrZSBvY3NwIHJlc3BvbnNlIDI=
+-----END OCSP RESPONSE-----)";
+  EXPECT_THAT(LoadOcspResponseFromPem(kPemOcspMultiple),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Multiple OCSP RESPONSE blocks found")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/testing/proto/BUILD b/testing/proto/BUILD
new file mode 100644
index 0000000..83d707b
--- /dev/null
+++ b/testing/proto/BUILD
@@ -0,0 +1,31 @@
+# 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.
+#
+
+load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library")
+load("@protobuf//bazel:proto_library.bzl", "proto_library")
+
+package(default_visibility = ["//visibility:public"])
+
+proto_library(
+    name = "test_message_proto",
+    srcs = ["test_message.proto"],
+    deps = [
+    ],
+)
+
+cc_proto_library(
+    name = "test_message_cc_proto",
+    deps = [":test_message_proto"],
+)
diff --git a/testing/proto/test_message.proto b/testing/proto/test_message.proto
new file mode 100644
index 0000000..78a3bb4
--- /dev/null
+++ b/testing/proto/test_message.proto
@@ -0,0 +1,155 @@
+// 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.
+//
+
+edition = "2024";
+
+package credentio_testing;
+
+message Message {
+  message NestedMessage {
+    message NestedNestedMessage {
+      string name = 1;
+      uint64 version = 2;
+    }
+
+    string required_name = 1;
+
+    uint64 version = 2;
+    NestedNestedMessage nested_nested_message = 3;
+  }
+
+  NestedMessage required_nested_message = 1;
+
+  uint64 required_uint64 = 2;
+
+  repeated NestedMessage repeated_nested_message = 3;
+
+  repeated NestedMessage required_repeated_nested_message = 4;
+
+  repeated NestedMessage required_non_empty_repeated_nested_message = 5;
+}
+
+message MessageContainingMessage {
+  Message msg = 1;
+}
+
+message Message2 {
+  message NestedMessage2 {
+    string name = 1;
+    uint64 version = 2;
+  }
+
+  NestedMessage2 nested_message = 1;
+}
+
+message MessageWithWriteOnlyField {
+  string name = 1;
+  uint64 version = 2;
+  bytes pad = 3;
+}
+
+message MessageOfBools {
+  bool required_bool = 1;
+
+  bool optional_bool = 2;
+
+  repeated bool repeated_bools = 3;
+
+  repeated bool required_repeated_bools = 4;
+
+  repeated bool required_non_empty_repeated_bools = 5;
+}
+
+message MessageOfFloats {
+  float required_float = 1;
+
+  float optional_float = 2;
+
+  repeated float repeated_floats = 3;
+
+  repeated float required_repeated_floats = 4;
+
+  repeated float required_non_empty_repeated_floats = 5;
+}
+
+message MessageOfDoubles {
+  double required_double = 1;
+
+  double optional_double = 2;
+
+  repeated double repeated_doubles = 3;
+
+  repeated double required_repeated_doubles = 4;
+
+  repeated double required_non_empty_repeated_doubles = 5;
+}
+
+message MessageOfInt64s {
+  int64 required_int64 = 1;
+
+  int64 optional_int64 = 2;
+
+  repeated int64 repeated_int64s = 3;
+
+  repeated int64 required_repeated_int64s = 4;
+
+  repeated int64 required_non_empty_repeated_int64s = 5;
+}
+
+message MessageOfStrings {
+  string required_string = 1;
+
+  string optional_string = 2;
+
+  repeated string repeated_strings = 3;
+
+  repeated string required_repeated_strings = 5;
+
+  repeated string required_non_empty_repeated_strings = 6;
+}
+
+message MessageOfUint64s {
+  uint64 required_uint64 = 1;
+
+  uint64 optional_uint64 = 2;
+
+  repeated uint64 repeated_uint64s = 3;
+
+  repeated uint64 required_repeated_uint64s = 4;
+
+  repeated uint64 required_non_empty_repeated_uint64s = 5;
+}
+
+message MessageOfByteStrings {
+  bytes required_byte_string = 1;
+
+  bytes optional_byte_string = 2;
+
+  repeated bytes repeated_byte_strings = 3;
+
+  repeated bytes required_repeated_byte_strings = 4;
+
+  repeated bytes required_non_empty_repeated_byte_strings = 5;
+}
+
+// Meant to fail conversion to CBOR.
+message MessageOfInt32 {
+  int32 required_int32 = 1;
+}
+
+// Meant to fail conversion to CBOR.
+message MessageOfRepeatedInt32 {
+  repeated int32 repeated_int32s = 1;
+}
diff --git a/testing/proto_test_utils.h b/testing/proto_test_utils.h
new file mode 100644
index 0000000..bf8ba88
--- /dev/null
+++ b/testing/proto_test_utils.h
@@ -0,0 +1,46 @@
+// 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_TESTING_PROTO_TEST_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_
+
+#include "absl/log/check.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/text_format.h"
+#include "google/protobuf/util/message_differencer.h"
+
+namespace credentio_testing {
+
+MATCHER_P(EqualsProto, expected, "") {
+  return google::protobuf::util::MessageDifferencer::Equals(arg, expected);
+}
+
+MATCHER_P(PartiallyEqualsProto, expected, "") {
+  google::protobuf::util::MessageDifferencer differencer;
+  differencer.set_scope(google::protobuf::util::MessageDifferencer::PARTIAL);
+  return differencer.Compare(expected, arg);
+}
+
+template <typename T>
+T ParseTextProtoOrDie(absl::string_view text_proto) {
+  T proto;
+  CHECK(google::protobuf::TextFormat::ParseFromString(text_proto, &proto));
+  return proto;
+}
+
+}  // namespace credentio_testing
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_
diff --git a/testing/test_file_utils.cc b/testing/test_file_utils.cc
new file mode 100644
index 0000000..4168b58
--- /dev/null
+++ b/testing/test_file_utils.cc
@@ -0,0 +1,72 @@
+// 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 "testing/test_file_utils.h"
+
+#include <fstream>
+#include <memory>
+#include <sstream>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/cfile_reader.h"
+#include "riegeli/bytes/reader.h"
+#include "tools/cpp/runfiles/runfiles.h"
+
+namespace credentio_testing {
+namespace {
+
+std::string GetAbsoluteFilePath(absl::string_view file_path) {
+  std::string error;
+  std::unique_ptr<bazel::tools::cpp::runfiles::Runfiles> runfiles(
+      bazel::tools::cpp::runfiles::Runfiles::CreateForTest(&error));
+
+  if (runfiles != nullptr) {
+    return runfiles->Rlocation(std::string(file_path));
+  }
+  return std::string(file_path);
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> GetContents(absl::string_view file_path) {
+  std::string actual_path = GetAbsoluteFilePath(file_path);
+
+  std::ifstream f(actual_path, std::ios::binary);
+  if (!f.is_open()) {
+    return absl::NotFoundError("Failed to open file: " + actual_path);
+  }
+
+  std::stringstream buffer;
+  buffer << f.rdbuf();
+  return buffer.str();
+}
+
+absl::StatusOr<std::unique_ptr<riegeli::Reader>> GetFileReader(
+    absl::string_view file_path) {
+  std::string actual_path = GetAbsoluteFilePath(file_path);
+
+  std::unique_ptr<riegeli::CFileReader<>> reader =
+      std::make_unique<riegeli::CFileReader<>>(actual_path);
+  if (!reader->ok()) {
+    return reader->status();
+  }
+  return std::move(reader);
+}
+
+}  // namespace credentio_testing
diff --git a/testing/test_file_utils.h b/testing/test_file_utils.h
new file mode 100644
index 0000000..89c55e8
--- /dev/null
+++ b/testing/test_file_utils.h
@@ -0,0 +1,37 @@
+// 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_TESTING_TEST_FILE_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_TEST_FILE_UTILS_H_
+
+#include <memory>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio_testing {
+
+// Reads file into a buffer or returns error.
+absl::StatusOr<std::string> GetContents(absl::string_view file_path);
+
+// Creates a file reader for the given file path or returns error.
+absl::StatusOr<std::unique_ptr<riegeli::Reader>> GetFileReader(
+    absl::string_view file_path);
+
+}  // namespace credentio_testing
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_TEST_FILE_UTILS_H_
diff --git a/testing/test_status_tracker.h b/testing/test_status_tracker.h
new file mode 100644
index 0000000..8a1a997
--- /dev/null
+++ b/testing/test_status_tracker.h
@@ -0,0 +1,51 @@
+// 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_TESTING_TEST_STATUS_TRACKER_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_TEST_STATUS_TRACKER_H_
+
+#include "constants/status_codes.h"
+#include "testing/test_validation_tracker.h"
+#include "utils/status_tracker.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+class TestStatusTracker : public StatusTracker {
+ public:
+  void RecordSuccess(SuccessStatusCode code,
+                     ValidationTracker::RecordOptions options) override {
+    tracker_.tracker().RecordSuccess(code, options);
+  }
+  void RecordFailure(FailureStatusCode code,
+                     ValidationTracker::RecordOptions options) override {
+    tracker_.tracker().RecordFailure(code, options);
+  }
+  void RecordInformational(InformationalStatusCode code,
+                           ValidationTracker::RecordOptions options) override {
+    tracker_.tracker().RecordInformational(code, options);
+  }
+
+  const ValidationStatusSet& GetStatusSet() const {
+    return tracker_.GetStatusSet();
+  }
+
+ private:
+  TestValidationTracker tracker_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_TEST_STATUS_TRACKER_H_
diff --git a/testing/test_string_utils.h b/testing/test_string_utils.h
new file mode 100644
index 0000000..3f8fa8b
--- /dev/null
+++ b/testing/test_string_utils.h
@@ -0,0 +1,56 @@
+// 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_TESTING_TEST_STRING_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_TEST_STRING_UTILS_H_
+
+#include <cstdint>
+#include <string>
+
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio_testing {
+
+inline std::string Uint8Str(uint8_t value) {
+  std::string s;
+  s.resize(sizeof(value));
+  s[0] = value;
+  return s;
+}
+
+inline std::string Uint16Str(uint16_t value) {
+  std::string s;
+  s.resize(sizeof(value));
+  riegeli::WriteBigEndian<uint16_t>(value, s.data());
+  return s;
+}
+
+inline std::string Uint32Str(uint32_t value) {
+  std::string s;
+  s.resize(sizeof(value));
+  riegeli::WriteBigEndian<uint32_t>(value, s.data());
+  return s;
+}
+
+inline std::string Uint64Str(uint64_t value) {
+  std::string s;
+  s.resize(sizeof(value));
+  riegeli::WriteBigEndian<uint64_t>(value, s.data());
+  return s;
+}
+
+}  // namespace credentio_testing
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_TEST_STRING_UTILS_H_
diff --git a/testing/test_validation_tracker.h b/testing/test_validation_tracker.h
new file mode 100644
index 0000000..ff938ce
--- /dev/null
+++ b/testing/test_validation_tracker.h
@@ -0,0 +1,122 @@
+// 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_TESTING_TEST_VALIDATION_TRACKER_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_TEST_VALIDATION_TRACKER_H_
+
+#include <string>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/strings/str_cat.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/repeated_ptr_field.h"
+#include "gtest/gtest.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+class TestValidationTracker {
+ public:
+  TestValidationTracker()
+      : tracker_("urn:uuid:1234", manifest_.mutable_validation()) {}
+
+  ValidationTracker& tracker() { return tracker_; }
+
+  absl::flat_hash_set<std::string> GetFailures() const {
+    return tracker_.GetFailures();
+  }
+
+  absl::flat_hash_set<std::string> GetSuccesses() const {
+    return tracker_.GetSuccesses();
+  }
+
+  absl::flat_hash_set<std::string> GetInformationals() const {
+    return tracker_.GetInformationals();
+  }
+
+  google::protobuf::RepeatedPtrField<ValidationStatus> GetFailureStatuses()
+      const {
+    return tracker_.GetFailureStatuses();
+  }
+
+  google::protobuf::RepeatedPtrField<ValidationStatus> GetSuccessStatuses()
+      const {
+    return tracker_.GetSuccessStatuses();
+  }
+
+  google::protobuf::RepeatedPtrField<ValidationStatus>
+  GetInformationalStatuses() const {
+    return tracker_.GetInformationalStatuses();
+  }
+
+  // Dump all statuses for test debugging.
+  const ValidationStatusSet& GetStatusSet() const {
+    return manifest_.validation();
+  }
+
+ private:
+  Manifest manifest_;
+  ValidationTracker tracker_;
+};
+
+// Matcher for use with the result of `GetFailures`.
+inline auto ContainsFailure(FailureStatusCode code) {
+  return testing::Contains(absl::StrCat(code));
+}
+
+// Matcher for use with the result of `GetSuccesses`.
+inline auto ContainsSuccess(SuccessStatusCode code) {
+  return testing::Contains(absl::StrCat(code));
+}
+
+// Matcher for use with the result of `GetInformationals`.
+inline auto ContainsInformational(InformationalStatusCode code) {
+  return testing::Contains(absl::StrCat(code));
+}
+
+// Matcher that applies to raw status protos (results of `GetFailureStatuses`,
+// `GetSuccessStatuses`, or `GetInformationalStatuses`). Note that despite
+// acting on protos, this is not a proto matcher, and so cannot be combined with
+// proto-matching modifiers such as `IgnoringRepeatedFieldOrdering`, etc.
+template <typename StatusCode, typename ExplanationMatcher, typename UrlMatcher>
+auto C2paStatusIs(StatusCode code, ExplanationMatcher explanation_matcher,
+                  UrlMatcher url_matcher) {
+  return testing::AllOf(
+      testing::Property("code", &ValidationStatus::code,
+                        testing::Eq(absl::StrCat(code))),
+      testing::Property("explanation", &ValidationStatus::explanation,
+                        explanation_matcher),
+      testing::Property("url", &ValidationStatus::url, url_matcher));
+}
+template <typename StatusCode, typename ExplanationMatcher>
+auto C2paStatusIs(StatusCode code, ExplanationMatcher explanation_matcher) {
+  return testing::AllOf(
+      testing::Property("code", &ValidationStatus::code,
+                        testing::Eq(absl::StrCat(code))),
+      testing::Property("explanation", &ValidationStatus::explanation,
+                        explanation_matcher));
+}
+template <typename StatusCode>
+auto C2paStatusIs(StatusCode code) {
+  return testing::Property("code", &ValidationStatus::code,
+                           testing::Eq(absl::StrCat(code)));
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_TEST_VALIDATION_TRACKER_H_
diff --git a/testing/testdata/keys/BUILD b/testing/testdata/keys/BUILD
new file mode 100644
index 0000000..1f64fa1
--- /dev/null
+++ b/testing/testdata/keys/BUILD
@@ -0,0 +1,24 @@
+# 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.
+#
+
+package(
+    default_testonly = True,
+    default_visibility = ["//visibility:public"],
+)
+
+filegroup(
+    name = "keys",
+    srcs = glob(["*.pem"]),
+)
diff --git a/testing/testdata/keys/test_ca/BUILD b/testing/testdata/keys/test_ca/BUILD
new file mode 100644
index 0000000..f4eea36
--- /dev/null
+++ b/testing/testdata/keys/test_ca/BUILD
@@ -0,0 +1,29 @@
+# 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.
+#
+
+package(
+    default_testonly = True,
+    default_visibility = ["//visibility:public"],
+)
+
+filegroup(
+    name = "certificates",
+    srcs = glob(["*.pem"]),
+)
+
+filegroup(
+    name = "keys",
+    srcs = glob(["*.key"]),
+)
diff --git a/testing/testdata/keys/test_ca/tsa.key b/testing/testdata/keys/test_ca/tsa.key
new file mode 100644
index 0000000..7a6ca05
--- /dev/null
+++ b/testing/testdata/keys/test_ca/tsa.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3SbKDokKKMUKAcCs
+c9tVRvPpbiwgNgLrpOmB1kX06fuhRANCAAS5S7LLvDCXQwkt1FeAj3CNZeJZ0O2O
+le4wGgF07e8G0ZzfJW43DJn82OJqoXOhzLRWKmI+FHju7bY5L8yNj3xp
+-----END PRIVATE KEY-----
diff --git a/testing/testdata/keys/test_ca/tsa.pem b/testing/testdata/keys/test_ca/tsa.pem
new file mode 100644
index 0000000..89bee90
--- /dev/null
+++ b/testing/testdata/keys/test_ca/tsa.pem
@@ -0,0 +1,51 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number:
+            69:43:37:b5:65:6e:71:3c:ef:55:65:0d:63:41:e3:14:a0:40:12:4c
+        Signature Algorithm: ecdsa-with-SHA256
+        Issuer: CN=Google C2PA Testing Root CA
+        Validity
+            Not Before: Feb  4 21:42:14 2025 GMT
+            Not After : Jan 11 21:42:14 2125 GMT
+        Subject: CN=Google C2PA Testing TSA
+        Subject Public Key Info:
+            Public Key Algorithm: id-ecPublicKey
+                Public-Key: (256 bit)
+                pub:
+                    04:b9:4b:b2:cb:bc:30:97:43:09:2d:d4:57:80:8f:
+                    70:8d:65:e2:59:d0:ed:8e:95:ee:30:1a:01:74:ed:
+                    ef:06:d1:9c:df:25:6e:37:0c:99:fc:d8:e2:6a:a1:
+                    73:a1:cc:b4:56:2a:62:3e:14:78:ee:ed:b6:39:2f:
+                    cc:8d:8f:7c:69
+                ASN1 OID: prime256v1
+                NIST CURVE: P-256
+        X509v3 extensions:
+            X509v3 Key Usage: critical
+                Digital Signature
+            X509v3 Extended Key Usage: critical
+                Time Stamping
+            X509v3 Subject Key Identifier: 
+                79:6C:92:AC:5E:6D:29:CD:82:C1:EE:C0:CE:4C:40:2D:75:57:C8:8C
+            X509v3 Authority Key Identifier: 
+                D3:30:EA:D0:AF:69:F2:F6:06:32:CB:03:C6:51:91:B1:FC:A1:0D:B7
+    Signature Algorithm: ecdsa-with-SHA256
+    Signature Value:
+        30:66:02:31:00:fc:02:28:a1:8c:ca:66:15:f2:9d:a0:5c:30:
+        f2:25:23:1b:1c:99:df:1e:ef:c5:bd:f7:9a:59:81:7a:6b:ab:
+        70:08:2b:7b:20:57:a1:25:66:f2:83:f7:f5:a7:fd:d0:ed:02:
+        31:00:ad:32:2c:8e:8b:fc:54:d0:3d:16:81:de:76:41:9b:83:
+        9c:c3:d4:19:d7:bd:6a:8a:c0:ca:77:5c:16:f3:79:a3:98:50:
+        29:f5:a3:45:94:5b:f6:c9:0b:7d:03:53:5e:11
+-----BEGIN CERTIFICATE-----
+MIIB1zCCAVygAwIBAgIUaUM3tWVucTzvVWUNY0HjFKBAEkwwCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw
+NDIxNDIxNFoYDzIxMjUwMTExMjE0MjE0WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ
+QSBUZXN0aW5nIFRTQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLlLssu8MJdD
+CS3UV4CPcI1l4lnQ7Y6V7jAaAXTt7wbRnN8lbjcMmfzY4mqhc6HMtFYqYj4UeO7t
+tjkvzI2PfGmjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEF
+BQcDCDAdBgNVHQ4EFgQUeWySrF5tKc2Cwe7AzkxALXVXyIwwHwYDVR0jBBgwFoAU
+0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaQAwZgIxAPwCKKGMymYV
+8p2gXDDyJSMbHJnfHu/FvfeaWYF6a6twCCt7IFehJWbyg/f1p/3Q7QIxAK0yLI6L
+/FTQPRaB3nZBm4Ocw9QZ171qisDKd1wW83mjmFAp9aNFlFv2yQt9A1NeEQ==
+-----END CERTIFICATE-----
diff --git a/testing/testdata/keys/trust_anchors.pem b/testing/testdata/keys/trust_anchors.pem
new file mode 100644
index 0000000..7ad0efb
--- /dev/null
+++ b/testing/testdata/keys/trust_anchors.pem
@@ -0,0 +1,54 @@
+-----BEGIN CERTIFICATE-----
+MIICUzCCAfmgAwIBAgIUdmkq4byvgk2FSnddHqB2yjoD68gwCgYIKoZIzj0EAwIw
+dzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUx
+GjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBGT1IgVEVTVElO
+R19PTkxZMRAwDgYDVQQDDAdSb290IENBMB4XDTIyMDYxMDE4NDY0MFoXDTMyMDYw
+NzE4NDY0MFowdzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlT
+b21ld2hlcmUxGjAYBgNVBAoMEUMyUEEgVGVzdCBSb290IENBMRkwFwYDVQQLDBBG
+T1IgVEVTVElOR19PTkxZMRAwDgYDVQQDDAdSb290IENBMFkwEwYHKoZIzj0CAQYI
+KoZIzj0DAQcDQgAEre/KpcWwGEHt+mD4xso3xotRnRx2IEsMoYwVIKI7iEJrDEye
+PcvJuBywA0qiMw2yvAvGOzW/fqUTu1jABrFIk6NjMGEwHQYDVR0OBBYEFF6ZuIbh
+eBvZVxVadQBStikOy6iMMB8GA1UdIwQYMBaAFF6ZuIbheBvZVxVadQBStikOy6iM
+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gA
+MEUCIHBC1xLwkCWSGhVXFlSnQBx9cGZivXzCbt8BuwRqPSUoAiEAteZQDk685yh9
+jgOTkp4H8oAmM1As+qlkRK2b+CHAQ3k=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIGezCCBC+gAwIBAgIUDAG5+sfGspprX+hlkn1SuB2f5VQwQQYJKoZIhvcNAQEK
+MDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEF
+AKIDAgEgMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29t
+ZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcGA1UECwwQRk9S
+IFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTAeFw0yMjA2MTAxODQ2MjVa
+Fw0zMjA2MDcxODQ2MjVaMHcxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAG
+A1UEBwwJU29tZXdoZXJlMRowGAYDVQQKDBFDMlBBIFRlc3QgUm9vdCBDQTEZMBcG
+A1UECwwQRk9SIFRFU1RJTkdfT05MWTEQMA4GA1UEAwwHUm9vdCBDQTCCAlYwQQYJ
+KoZIhvcNAQEKMDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglg
+hkgBZQMEAgEFAKIDAgEgA4ICDwAwggIKAoICAQC4q3t327HRHDs7Y9NR+ZqernwU
+bZ1EiEBR8vKTZ9StXmSfkzgSnvVfsFanvrKuZvFIWq909t/gH2z0klI2ZtChwLi6
+TFYXQjzQt+x5CpRcdWnB9zfUhOpdUHAhRd03Q14H2MyAiI98mqcVreQOiLDydlhP
+Dla7Ign4PqedXBH+NwUCEcbQIEr2LvkZ5fzX1GzBtqymClT/Gqz75VO7zM1oV4gq
+ElFHLsTLgzv5PR7pydcHauoTvFWhZNgz5s3olXJDKG/n3h0M3vIsjn11OXkcwq99
+Ne5Nm9At2tC1w0Huu4iVdyTLNLIAfM368ookf7CJeNrVJuYdERwLwICpetYvOnid
+VTLSDt/YK131pR32XCkzGnrIuuYBm/k6IYgNoWqUhojGJai6o5hI1odAzFIWr9T0
+sa9f66P6RKl4SUqa/9A/uSS8Bx1gSbTPBruOVm6IKMbRZkSNN/O8dgDa1OftYCHD
+blCCQh9DtOSh6jlp9I6iOUruLls7d4wPDrstPefi0PuwsfWAg4NzBtQ3uGdzl/lm
+yusq6g94FVVq4RXHN/4QJcitE9VPpzVuP41aKWVRM3X/q11IH80rtaEQt54QMJwi
+sIv4eEYW3TYY9iQtq7Q7H9mcz60ClJGYQJvd1DR7lA9LtUrnQJIjNY9v6OuHVXEX
+EFoDH0viraraHozMdwIDAQABo2MwYTAdBgNVHQ4EFgQURW8b4nQuZgIteSw5+foy
+TZQrGVAwHwYDVR0jBBgwFoAURW8b4nQuZgIteSw5+foyTZQrGVAwDwYDVR0TAQH/
+BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwQQYJKoZIhvcNAQEKMDSgDzANBglghkgB
+ZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEFAKIDAgEgA4ICAQBB
+WnUOG/EeQoisgC964H5+ns4SDIYFOsNeksJM3WAd0yG2L3CEjUksUYugQzB5hgh4
+BpsxOajrkKIRxXN97hgvoWwbA7aySGHLgfqH1vsGibOlA5tvRQX0WoQ+GMnuliVM
+pLjpHdYE2148DfgaDyIlGnHpc4gcXl7YHDYcvTN9NV5Y4P4x/2W/Lh11NC/VOSM9
+aT+jnFE7s7VoiRVfMN2iWssh2aihecdE9rs2w+Wt/E/sCrVClCQ1xaAO1+i4+mBS
+a7hW+9lrQKSx2bN9c8K/CyXgAcUtutcIh5rgLm2UWOaB9It3iw0NVaxwyAgWXC9F
+qYJsnia4D3AP0TJL4PbpNUaA4f2H76NODtynMfEoXSoG3TYYpOYKZ65lZy3mb26w
+fvBfrlASJMClqdiEFHfGhP/dTAZ9eC2cf40iY3ta84qSJybSYnqst8Vb/Gn+dYI9
+qQm0yVHtJtvkbZtgBK5Vg6f5q7I7DhVINQJUVlWzRo6/Vx+/VBz5tC5aVDdqtBAs
+q6ZcYS50ECvK/oGnVxjpeOafGvaV2UroZoGy7p7bEoJhqOPrW2yZ4JVNp9K6CCRg
+zR6jFN/gUe42P1lIOfcjLZAM1GHixtjP5gLAp6sJS8X05O8xQRBtnOsEwNLj5w0y
+MAdtwAzT/Vfv7b08qfx4FfQPFmtjvdu4s82gNatxSA==
+-----END CERTIFICATE-----
+
+
diff --git a/testing/testdata/keys/trust_anchors_info.txt b/testing/testdata/keys/trust_anchors_info.txt
new file mode 100644
index 0000000..f2317f8
--- /dev/null
+++ b/testing/testdata/keys/trust_anchors_info.txt
@@ -0,0 +1,5 @@
+`trust_anchors.pem` contains the trust list used by the `c2patool`.
+
+The trust list will be used to validate test assets created by the `c2patool` in the validator tests only.
+
+The original file can be found at https://github.com/contentauth/c2patool/blob/main/sample/trust_anchors.pem
diff --git a/tools/BUILD b/tools/BUILD
new file mode 100644
index 0000000..7322fe9
--- /dev/null
+++ b/tools/BUILD
@@ -0,0 +1,41 @@
+# 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.
+#
+
+load("@rules_cc//cc:defs.bzl", "cc_binary")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_binary(
+    name = "c2pa_validate",
+    srcs = ["asset_validator_main.cc"],
+    deps = [
+        "//crypto:crypto_read_handler",
+        "//crypto/default:default_crypto_read_handler",
+        "//utils:media_type",
+        "//validator:asset_validator_impl",
+        "//validator:result",
+        "//validator:validator_options",
+        "@abseil-cpp//absl/flags:flag",
+        "@abseil-cpp//absl/flags:parse",
+        "@abseil-cpp//absl/flags:usage",
+        "@abseil-cpp//absl/log:initialize",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@protobuf",
+        "@riegeli//riegeli/bytes:cfile_reader",
+    ],
+)
diff --git a/tools/asset_validator_main.cc b/tools/asset_validator_main.cc
new file mode 100644
index 0000000..700cd54
--- /dev/null
+++ b/tools/asset_validator_main.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 <fstream>
+#include <iostream>
+#include <memory>
+#include <optional>
+#include <sstream>
+#include <string>
+#include <utility>
+
+#include "absl/flags/flag.h"
+#include "absl/flags/parse.h"
+#include "absl/flags/usage.h"
+#include "absl/log/initialize.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/default/default_crypto_read_handler.h"
+#include "google/protobuf/text_format.h"
+#include "riegeli/bytes/cfile_reader.h"
+#include "utils/media_type.h"
+#include "validator/asset_validator_impl.h"
+#include "validator/result.h"
+#include "validator/validator_options.h"
+
+ABSL_FLAG(std::string, asset, "",
+          "Path to the asset file to validate (Required).");
+ABSL_FLAG(
+    std::string, claim_signer_trust, "",
+    "Path to PEM file containing claim signer trust anchors (for C2PA "
+    "2.2+ strict EKU compliance). If not provided, then the validator will "
+    "default to skip trust checks.");
+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.");
+
+namespace {
+
+absl::StatusOr<std::string> GetContents(absl::string_view file_path) {
+  std::ifstream f(std::string(file_path), std::ios::binary);
+  if (!f.is_open()) {
+    return absl::NotFoundError(
+        absl::StrCat("Failed to open file: ", file_path));
+  }
+  std::stringstream buffer;
+  buffer << f.rdbuf();
+  return buffer.str();
+}
+
+}  // 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>]");
+  absl::InitializeLog();
+  absl::ParseCommandLine(argc, argv);
+
+  const std::string asset_path = absl::GetFlag(FLAGS_asset);
+  if (asset_path.empty()) {
+    std::cerr << "Error: --asset flag is required.\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);
+
+  credentio::DefaultCryptoReadHandlerOptions crypto_options;
+  std::string claim_signer_trust_pem;
+  std::string tsa_trust_pem;
+
+  if (!claim_signer_trust_path.empty()) {
+    absl::StatusOr<std::string> pem = GetContents(claim_signer_trust_path);
+    if (!pem.ok()) {
+      std::cerr << "Error reading claim signer trust file: " << pem.status()
+                << "\n";
+      return 1;
+    }
+    claim_signer_trust_pem = *std::move(pem);
+    crypto_options.claim_signer_trust_anchors_pem = claim_signer_trust_pem;
+  } else {
+    std::cerr << "WARNING: --claim_signer_trust is not provided. Skipping "
+                 "claim signer trust checks.\n";
+    crypto_options.skip_claim_signer_trust_checks_for_test = true;
+  }
+
+  if (!tsa_trust_path.empty()) {
+    absl::StatusOr<std::string> pem = GetContents(tsa_trust_path);
+    if (!pem.ok()) {
+      std::cerr << "Error reading TSA trust file: " << pem.status() << "\n";
+      return 1;
+    }
+    tsa_trust_pem = *std::move(pem);
+    crypto_options.tsa_trust_anchors_pem = tsa_trust_pem;
+  } else {
+    std::cerr
+        << "WARNING: --tsa_trust is not provided. Skipping TSA trust checks.\n";
+    crypto_options.skip_tsa_trust_checks_for_test = true;
+  }
+
+  absl::StatusOr<std::unique_ptr<credentio::CryptoReadHandler>>
+      crypto_read_handler =
+          credentio::CreateDefaultCryptoReadHandler(crypto_options);
+  if (!crypto_read_handler.ok()) {
+    std::cerr << "Error creating crypto read handler: "
+              << crypto_read_handler.status() << "\n";
+    return 1;
+  }
+
+  credentio::AssetValidatorImpl validator(credentio::ValidatorOptions{
+      .crypto_read_handler = *std::move(crypto_read_handler),
+  });
+
+  riegeli::CFileReader<> reader(asset_path);
+  if (!reader.ok()) {
+    std::cerr << "Error opening asset file: " << reader.status() << "\n";
+    return 1;
+  }
+
+  std::optional<absl::string_view> media_type_opt;
+  const absl::StatusOr<std::string> media_type =
+      credentio::MediaType(asset_path);
+  if (media_type.ok()) {
+    media_type_opt = *media_type;
+  } else {
+    std::cerr << "Error determining media type: " << media_type.status()
+              << "\n";
+  }
+
+  const absl::StatusOr<std::unique_ptr<credentio::ValidationResult>> result =
+      validator.Validate(reader, media_type_opt);
+  if (!result.ok()) {
+    std::cerr << "Validation failed: " << result.status() << "\n";
+    return 1;
+  }
+
+  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";
+    return 1;
+  }
+
+  return 0;
+}
diff --git a/tsp/BUILD b/tsp/BUILD
new file mode 100644
index 0000000..626b70e
--- /dev/null
+++ b/tsp/BUILD
@@ -0,0 +1,224 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "status_codes",
+    srcs = ["status_codes.cc"],
+    hdrs = ["status_codes.h"],
+    deps = [
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+    ],
+)
+
+cc_test(
+    name = "status_codes_test",
+    srcs = ["status_codes_test.cc"],
+    deps = [
+        ":status_codes",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "verified_timestamp",
+    hdrs = ["verified_timestamp.h"],
+    deps = [
+        "//crypto:algorithms",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_library(
+    name = "parsed_timestamp_token",
+    hdrs = ["parsed_timestamp_token.h"],
+    deps = [
+        "//crypto:algorithms",
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "cms_parser",
+    hdrs = ["cms_parser.h"],
+    deps = [
+        ":parsed_timestamp_token",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "constants",
+    srcs = ["constants.cc"],
+    hdrs = ["constants.h"],
+    deps = [
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/strings:string_view",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_library(
+    name = "timestamp_parsing",
+    srcs = ["timestamp_parsing.cc"],
+    hdrs = ["timestamp_parsing.h"],
+    deps = [
+        ":constants",
+        ":status_codes",
+        "//crypto:algorithms",
+        "//crypto:cbs_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@boringssl//:crypto",
+    ],
+)
+
+cc_library(
+    name = "test_helpers",
+    srcs = ["test_helpers.cc"],
+    hdrs = ["test_helpers.h"],
+    deps = [
+        ":constants",
+        ":status_codes",
+        "//crypto:algorithms",
+        "//crypto:cbs_utils",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//testing:boringssl_utils",
+        "//testing:cms",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+        "@tink_cc//proto:common_cc_proto",
+        "@tink_cc//tink:cleartext_keyset_handle",
+        "@tink_cc//tink:keyset_handle",
+        "@tink_cc//tink:public_key_sign",
+        "@tink_cc//tink/signature:config_v0",
+        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+    ],
+)
+
+cc_test(
+    name = "timestamp_parsing_test",
+    srcs = ["timestamp_parsing_test.cc"],
+    data = [
+        "//testing/testdata/keys/test_ca:certificates",
+        "//testing/testdata/keys/test_ca:keys",
+    ],
+    deps = [
+        ":constants",
+        ":status_codes",
+        ":test_helpers",
+        ":timestamp_parsing",
+        "//crypto:algorithms",
+        "//crypto:cbs_utils",
+        "//testing:test_file_utils",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "timestamp_verifier",
+    srcs = ["timestamp_verifier.cc"],
+    hdrs = ["timestamp_verifier.h"],
+    deps = [
+        ":parsed_timestamp_token",
+        ":timestamp_parsing",
+        ":verified_timestamp",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:absl_log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+        "@boringssl//:pki",
+    ],
+)
+
+cc_test(
+    name = "timestamp_verifier_test",
+    srcs = ["timestamp_verifier_test.cc"],
+    deps = [
+        ":constants",
+        ":parsed_timestamp_token",
+        ":test_helpers",
+        ":timestamp_verifier",
+        ":verified_timestamp",
+        "//crypto:algorithms",
+        "//crypto:cbs_utils",
+        "//crypto/default:trust_store",
+        "//proto:validation_result_cc_proto",
+        "@abseil-cpp//absl/algorithm:container",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@boringssl//:crypto",
+        "@boringssl//:pki",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/tsp/cms_parser.h b/tsp/cms_parser.h
new file mode 100644
index 0000000..4abba39
--- /dev/null
+++ b/tsp/cms_parser.h
@@ -0,0 +1,39 @@
+// 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_TSP_CMS_PARSER_H_
+#define THIRD_PARTY_CREDENTIO_TSP_CMS_PARSER_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "tsp/parsed_timestamp_token.h"
+
+namespace credentio {
+
+// Interface for parsing CMS TimestampTokens.
+class CmsParser {
+ public:
+  virtual ~CmsParser() = default;
+
+  // Parses a CMS token and ensures its content type is id-ct-TSTInfo.
+  virtual absl::StatusOr<std::unique_ptr<ParsedTimestampToken>>
+  ParseTimestampToken(absl::string_view timestamp_token) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TSP_CMS_PARSER_H_
diff --git a/tsp/constants.cc b/tsp/constants.cc
new file mode 100644
index 0000000..bd247fb
--- /dev/null
+++ b/tsp/constants.cc
@@ -0,0 +1,30 @@
+// 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 "tsp/constants.h"
+
+#include "absl/log/die_if_null.h"
+#include "openssl/base.h"
+#include "openssl/obj.h"
+
+namespace credentio {
+
+const ASN1_OBJECT* IdAaSigningCertificateV2() {
+  static const ASN1_OBJECT* singleton = ABSL_DIE_IF_NULL(
+      OBJ_txt2obj("1.2.840.113549.1.9.16.2.47", /*dont_search_names=*/true));
+  return singleton;
+}
+
+}  // namespace credentio
diff --git a/tsp/constants.h b/tsp/constants.h
new file mode 100644
index 0000000..538470e
--- /dev/null
+++ b/tsp/constants.h
@@ -0,0 +1,32 @@
+// 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_TSP_CONSTANTS_H_
+#define THIRD_PARTY_CREDENTIO_TSP_CONSTANTS_H_
+
+#include "absl/strings/string_view.h"
+#include "openssl/base.h"
+
+namespace credentio {
+// Format string for GenTime values. Compatible with `absl::FormatTime` and
+// `absl::ParseTime`.
+constexpr inline absl::string_view kGenTimeFormat = "%E4Y%m%d%H%M%E*SZ";
+
+// The OID `id-aa-signingCertificateV2` as defined by RFC 5035.
+const ASN1_OBJECT* IdAaSigningCertificateV2();
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TSP_CONSTANTS_H_
diff --git a/tsp/parsed_timestamp_token.h b/tsp/parsed_timestamp_token.h
new file mode 100644
index 0000000..7ab82eb
--- /dev/null
+++ b/tsp/parsed_timestamp_token.h
@@ -0,0 +1,64 @@
+// 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_TSP_PARSED_TIMESTAMP_TOKEN_H_
+#define THIRD_PARTY_CREDENTIO_TSP_PARSED_TIMESTAMP_TOKEN_H_
+
+#include <string>
+#include <vector>
+
+#include "absl/base/attributes.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+
+namespace credentio {
+
+// Represents a successfully parsed CMS TimestampToken ready for extraction.
+class ParsedTimestampToken {
+ public:
+  virtual ~ParsedTimestampToken() = default;
+
+  // Verifies the signature of this token. Note that this does not perform trust
+  // checks.
+  virtual absl::Status VerifySignature() const = 0;
+
+  // Returns the hash algorithm used to calculate the message imprint.
+  virtual absl::StatusOr<HashAlgorithm> GetMessageImprintHashAlgorithm()
+      const = 0;
+
+  // Returns the certificate chain of the TSA certificate. The first element
+  // is the leaf certificate, followed by any intermediate certificates.
+  // The certificates are DER encoded.
+  virtual absl::StatusOr<std::vector<std::string>> GetCertificateChain()
+      const = 0;
+
+  // Extracts the DER-encoded SigningCertificateV2 attribute from the signer.
+  // The lifetime of the returned absl::string_view is tied to this
+  // ParsedTimestampToken instance.
+  virtual absl::StatusOr<absl::string_view> GetSigningCertificateV2Bytes() const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND = 0;
+
+  // Gets the raw DER-encoded TstInfo content.
+  // The lifetime of the returned absl::string_view is tied to this
+  // ParsedTimestampToken instance.
+  virtual absl::StatusOr<absl::string_view> GetTstInfoBytes() const
+      ABSL_ATTRIBUTE_LIFETIME_BOUND = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TSP_PARSED_TIMESTAMP_TOKEN_H_
diff --git a/tsp/status_codes.cc b/tsp/status_codes.cc
new file mode 100644
index 0000000..29b40f0
--- /dev/null
+++ b/tsp/status_codes.cc
@@ -0,0 +1,50 @@
+// 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 "tsp/status_codes.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+
+namespace credentio {
+
+absl::StatusOr<TspPkiStatus> GetTspPkiStatus(int64_t status_code) {
+  switch (status_code) {
+    case 0:
+      return TspPkiStatus::kGranted;
+    case 1:
+      return TspPkiStatus::kGrantedWithMods;
+    case 2:
+      return TspPkiStatus::kRejection;
+    case 3:
+      return TspPkiStatus::kWaiting;
+    case 4:
+      return TspPkiStatus::kRevocationWarning;
+    case 5:
+      return TspPkiStatus::kRevocationNotification;
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("unknown TspPkiStatus code: ", status_code));
+}
+
+bool IsTspPkiStatusOk(TspPkiStatus status_code) {
+  return status_code == TspPkiStatus::kGranted ||
+         status_code == TspPkiStatus::kGrantedWithMods;
+}
+
+}  // namespace credentio
diff --git a/tsp/status_codes.h b/tsp/status_codes.h
new file mode 100644
index 0000000..8c49fbf
--- /dev/null
+++ b/tsp/status_codes.h
@@ -0,0 +1,72 @@
+// 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_TSP_STATUS_CODES_H_
+#define THIRD_PARTY_CREDENTIO_TSP_STATUS_CODES_H_
+
+#include <cstdint>
+
+#include "absl/log/log.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+
+namespace credentio {
+
+// The status codes as defined by RFC 3161 section 2.4.2.
+enum class TspPkiStatus {
+  kGranted = 0,
+  kGrantedWithMods = 1,
+  kRejection = 2,
+  kWaiting = 3,
+  kRevocationWarning = 4,
+  kRevocationNotification = 5,
+};
+
+template <typename Sink>
+inline void AbslStringify(Sink& sink, TspPkiStatus status) {
+  switch (status) {
+    case TspPkiStatus::kGranted:
+      sink.Append("granted");
+      return;
+    case TspPkiStatus::kGrantedWithMods:
+      sink.Append("grantedWithMods");
+      return;
+    case TspPkiStatus::kRejection:
+      sink.Append("rejection");
+      return;
+    case TspPkiStatus::kWaiting:
+      sink.Append("waiting");
+      return;
+    case TspPkiStatus::kRevocationWarning:
+      sink.Append("revocationWarning");
+      return;
+    case TspPkiStatus::kRevocationNotification:
+      sink.Append("revocationNotification");
+      return;
+  }
+  LOG(DFATAL) << "unknown TspPkiStatus enum: " << static_cast<int>(status);
+  sink.Append(
+      absl::StrCat("UNKNOWN_TSP_PKI_STATUS_", static_cast<int>(status)));
+}
+
+absl::StatusOr<TspPkiStatus> GetTspPkiStatus(int64_t status_code);
+
+// Returns whether `status_code` would be considered successful if received in a
+// TimeStampResp.
+bool IsTspPkiStatusOk(TspPkiStatus status_code);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TSP_STATUS_CODES_H_
diff --git a/tsp/status_codes_test.cc b/tsp/status_codes_test.cc
new file mode 100644
index 0000000..7d60a3c
--- /dev/null
+++ b/tsp/status_codes_test.cc
@@ -0,0 +1,66 @@
+// 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 "tsp/status_codes.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+
+TEST(StatusCodesTest, GetTspPkiStatusTest) {
+  EXPECT_THAT(GetTspPkiStatus(0), IsOkAndHolds(TspPkiStatus::kGranted));
+  EXPECT_THAT(GetTspPkiStatus(1), IsOkAndHolds(TspPkiStatus::kGrantedWithMods));
+  EXPECT_THAT(GetTspPkiStatus(2), IsOkAndHolds(TspPkiStatus::kRejection));
+  EXPECT_THAT(GetTspPkiStatus(3), IsOkAndHolds(TspPkiStatus::kWaiting));
+  EXPECT_THAT(GetTspPkiStatus(4),
+              IsOkAndHolds(TspPkiStatus::kRevocationWarning));
+  EXPECT_THAT(GetTspPkiStatus(5),
+              IsOkAndHolds(TspPkiStatus::kRevocationNotification));
+
+  EXPECT_THAT(GetTspPkiStatus(6), StatusIs(absl::StatusCode::kInvalidArgument,
+                                           "unknown TspPkiStatus code: 6"));
+  EXPECT_THAT(GetTspPkiStatus(-1), StatusIs(absl::StatusCode::kInvalidArgument,
+                                            "unknown TspPkiStatus code: -1"));
+}
+
+TEST(StatusCodesTest, AbslStringifyTspPkiStatus) {
+  EXPECT_EQ(absl::StrCat(TspPkiStatus::kGranted), "granted");
+  EXPECT_EQ(absl::StrCat(TspPkiStatus::kGrantedWithMods), "grantedWithMods");
+  EXPECT_EQ(absl::StrCat(TspPkiStatus::kRejection), "rejection");
+  EXPECT_EQ(absl::StrCat(TspPkiStatus::kWaiting), "waiting");
+  EXPECT_EQ(absl::StrCat(TspPkiStatus::kRevocationWarning),
+            "revocationWarning");
+  EXPECT_EQ(absl::StrCat(TspPkiStatus::kRevocationNotification),
+            "revocationNotification");
+}
+
+TEST(StatusCodesTest, AbslStringifyInvalidTspPkiStatus) {
+  EXPECT_DEBUG_DEATH(
+      { std::string result = absl::StrCat(static_cast<TspPkiStatus>(100)); },
+      "unknown TspPkiStatus enum: 100");
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/tsp/test_helpers.cc b/tsp/test_helpers.cc
new file mode 100644
index 0000000..07f3432
--- /dev/null
+++ b/tsp/test_helpers.cc
@@ -0,0 +1,462 @@
+// 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 "tsp/test_helpers.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <variant>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/log/absl_check.h"
+#include "absl/log/check.h"
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "crypto/cbs_utils.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "openssl/base.h"
+#include "openssl/bio.h"
+#include "openssl/bytestring.h"
+#include "openssl/digest.h"
+#include "openssl/err.h"
+#include "openssl/evp.h"
+#include "openssl/mem.h"
+#include "openssl/obj.h"
+#include "openssl/pem.h"  // IWYU pragma: keep
+#include "openssl/x509.h"
+#include "proto/common.pb.h"
+#include "testing/boringssl_utils.h"
+#include "testing/cms.h"
+#include "tink/cleartext_keyset_handle.h"
+#include "tink/keyset_handle.h"
+#include "tink/public_key_sign.h"
+#include "tink/signature/config_v0.h"
+#include "tink/signature/signature_pem_keyset_reader.h"
+#include "tsp/constants.h"
+#include "tsp/status_codes.h"
+
+namespace credentio {
+namespace {
+
+using ::crypto::tink::KeysetHandle;
+using ::crypto::tink::PublicKeySign;
+using ::crypto::tink::SignaturePemKeysetReaderBuilder;
+
+absl::StatusOr<std::unique_ptr<crypto::tink::KeysetHandle>>
+LoadEcdsaPrivateKeyFromPem(absl::string_view pem_data) {
+  crypto::tink::SignaturePemKeysetReaderBuilder builder(
+      SignaturePemKeysetReaderBuilder::PemReaderType::PUBLIC_KEY_SIGN);
+  builder.Add({.serialized_key = std::string(pem_data),
+               .parameters = {
+                   .key_type = crypto::tink::PemKeyType::PEM_EC,
+                   .algorithm = crypto::tink::PemAlgorithm::ECDSA_DER,
+                   .key_size_in_bits = 256,
+                   .hash_type = google::crypto::tink::HashType::SHA256,
+               }});
+  ABSL_ASSIGN_OR_RETURN(auto reader, builder.Build());
+  return crypto::tink::CleartextKeysetHandle::Read(std::move(reader));
+}
+
+absl::StatusOr<std::vector<uint8_t>> SignWithBoringSsl(
+    const EVP_PKEY& private_key, absl::string_view data) {
+  if (EVP_PKEY_id(&private_key) != EVP_PKEY_RSA) {
+    return absl::UnimplementedError("Only RSA keys are supported.");
+  }
+  bssl::ScopedEVP_MD_CTX md_context;
+  EVP_PKEY_CTX* pkey_ctx = nullptr;
+  if (EVP_DigestSignInit(md_context.get(), &pkey_ctx, EVP_sha256(), nullptr,
+                         const_cast<EVP_PKEY*>(&private_key)) != 1 ||
+      EVP_DigestSignUpdate(md_context.get(), data.data(), data.length()) != 1) {
+    return absl::InternalError("EVP_DigestSignInit/Update failed.");
+  }
+  size_t sig_len = 0;
+  if (EVP_DigestSignFinal(md_context.get(), nullptr, &sig_len) != 1) {
+    return absl::InternalError("EVP_DigestSignFinal failed.");
+  }
+  std::vector<uint8_t> signature(sig_len, 0);
+  if (EVP_DigestSignFinal(md_context.get(), signature.data(), &sig_len) != 1) {
+    return absl::InternalError("EVP_DigestSignFinal failed.");
+  }
+  return signature;
+}
+
+}  // namespace
+
+std::string WrapDerSequence(absl::string_view payload) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, payload.size() + 5));
+  CBB seq_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &seq_cbb, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(CBB_add_bytes(&seq_cbb,
+                           reinterpret_cast<const uint8_t*>(payload.data()),
+                           payload.size()));
+  uint8_t* data;
+  size_t len;
+  ABSL_CHECK(CBB_finish(&cbb, &data, &len));
+  bssl::UniquePtr<uint8_t> data_uniq(data);
+  return std::string(reinterpret_cast<char*>(data), len);
+}
+
+std::string EncodeDerInteger(uint64_t n) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 10));
+  ABSL_CHECK(CBB_add_asn1_int64(&cbb, n));
+  uint8_t* cbb_data;
+  size_t cbb_len;
+  ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
+  bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
+  return std::string(reinterpret_cast<char*>(cbb_data), cbb_len);
+}
+
+std::string CreateTimeStampResp(absl::string_view ts_token) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 40));
+  CBB ts_resp_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &ts_resp_cbb, CBS_ASN1_SEQUENCE));
+
+  CBB pki_status_cbb;
+  ABSL_CHECK(CBB_add_asn1(&ts_resp_cbb, &pki_status_cbb, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(CBB_add_asn1_int64(&pki_status_cbb,
+                                static_cast<int64_t>(TspPkiStatus::kGranted)));
+
+  ABSL_CHECK(CBB_add_bytes(&ts_resp_cbb,
+                           reinterpret_cast<const uint8_t*>(ts_token.data()),
+                           ts_token.size()));
+
+  uint8_t* cbb_data_ptr;
+  size_t cbb_len;
+  ABSL_CHECK(CBB_finish(&cbb, &cbb_data_ptr, &cbb_len));
+  bssl::UniquePtr<uint8_t> cbb_uniq(cbb_data_ptr);
+
+  std::string cbb_str(cbb_data_ptr, cbb_data_ptr + cbb_len);
+  return cbb_str;
+}
+
+std::string CreateFailedTimeStampResp(
+    TspPkiStatus status, absl::Span<const absl::string_view> status_string,
+    absl::Span<const int32_t> failure_info) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 512));
+  CBB resp;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &resp, CBS_ASN1_SEQUENCE));
+  CBB pki_status_info;
+  ABSL_CHECK(CBB_add_asn1(&resp, &pki_status_info, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(
+      CBB_add_asn1_int64(&pki_status_info, static_cast<int64_t>(status)));
+  if (!status_string.empty()) {
+    CBB status_string_cbb;
+    ABSL_CHECK(
+        CBB_add_asn1(&pki_status_info, &status_string_cbb, CBS_ASN1_SEQUENCE));
+    for (absl::string_view entry : status_string) {
+      CBB utf8_string;
+      ABSL_CHECK(
+          CBB_add_asn1(&status_string_cbb, &utf8_string, CBS_ASN1_UTF8STRING));
+      ABSL_CHECK(CBB_add_bytes(&utf8_string,
+                               reinterpret_cast<const uint8_t*>(entry.data()),
+                               entry.length()));
+      ABSL_CHECK(CBB_flush(&status_string_cbb));
+    }
+    ABSL_CHECK(CBB_flush(&pki_status_info));
+  }
+  if (!failure_info.empty()) {
+    CBB failure_info_cbb;
+    ABSL_CHECK(
+        CBB_add_asn1(&pki_status_info, &failure_info_cbb, CBS_ASN1_BITSTRING));
+    ABSL_CHECK(CBB_add_u8(&failure_info_cbb, 0 /* no padding bits */));
+    int32_t max_bit = 0;
+    for (int32_t entry : failure_info) {
+      if (entry > max_bit) {
+        max_bit = entry;
+      }
+    }
+    int32_t bytes = (max_bit / 8) + 1;
+    std::string data(bytes, 0);
+    for (int32_t entry : failure_info) {
+      data[entry >> 3] |= 1 << (7 - (entry & 7));
+    }
+    ABSL_CHECK(CBB_add_bytes(&failure_info_cbb,
+                             reinterpret_cast<const uint8_t*>(data.data()),
+                             data.size()));
+    ABSL_CHECK(CBB_flush(&pki_status_info));
+  }
+
+  uint8_t* cbb_data;
+  size_t cbb_len;
+  ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
+  bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
+
+  return std::string(reinterpret_cast<char*>(cbb_data), cbb_len);
+}
+
+std::string CreateTimeStampTokenRsa(
+    absl::Span<const absl::string_view> der_data, absl::string_view key_pem,
+    absl::string_view cert_pem, bool embed_cert,
+    std::variant<absl::string_view, bool> signing_certificate_attribute) {
+  bssl::UniquePtr<EVP_PKEY> key(PemToKey(key_pem));
+  ABSL_CHECK(key != nullptr);
+  bssl::UniquePtr<X509> cert(PemToCertificate(cert_pem));
+  ABSL_CHECK(cert != nullptr);
+  std::string extra_attrs;
+  if (std::holds_alternative<absl::string_view>(
+          signing_certificate_attribute)) {
+    extra_attrs = std::get<absl::string_view>(signing_certificate_attribute);
+  } else {
+    if (std::get<bool>(signing_certificate_attribute)) {
+      X509* certs[] = {cert.get()};
+      extra_attrs =
+          CreateSigningCertificateV2Attribute(certs, HashAlgorithm::kSha256);
+    }
+  }
+
+  auto cms_or = credentio_testing::CreateSignedCms(
+      der_data, *cert,
+      [&key](absl::string_view data) { return SignWithBoringSsl(*key, data); },
+      {.signing_time = absl::FromUnixSeconds(1427738483),
+       .include_user_certificate = embed_cert,
+       .extra_signed_attributes = extra_attrs,
+       .is_time_stamp_token = true});
+  ABSL_CHECK_OK(cms_or.status());
+  return *cms_or;
+}
+
+std::string CreateTimeStampTokenEcdsa(
+    absl::Span<const absl::string_view> der_data,
+    const crypto::tink::PublicKeySign& signer, absl::string_view cert_pem,
+    bool embed_cert,
+    std::variant<absl::string_view, bool> signing_certificate_attribute) {
+  bssl::UniquePtr<X509> cert(PemToCertificate(cert_pem));
+  ABSL_CHECK(cert != nullptr);
+  std::string extra_attrs;
+  if (std::holds_alternative<absl::string_view>(
+          signing_certificate_attribute)) {
+    extra_attrs = std::get<absl::string_view>(signing_certificate_attribute);
+  } else {
+    if (std::get<bool>(signing_certificate_attribute)) {
+      X509* certs[] = {cert.get()};
+      extra_attrs =
+          CreateSigningCertificateV2Attribute(certs, HashAlgorithm::kSha256);
+    }
+  }
+
+  auto cms_or = credentio_testing::CreateSignedCms(
+      der_data, *cert,
+      [&signer](
+          absl::string_view data) -> absl::StatusOr<std::vector<uint8_t>> {
+        ABSL_ASSIGN_OR_RETURN(auto signature, signer.Sign(data));
+        return std::vector<uint8_t>(signature.begin(), signature.end());
+      },
+      {.signing_time = absl::FromUnixSeconds(1427738483),
+       .include_user_certificate = embed_cert,
+       .extra_signed_attributes = extra_attrs,
+       .is_time_stamp_token = true});
+  ABSL_CHECK_OK(cms_or.status());
+  return *cms_or;
+}
+
+std::string CreateTimeStampTokenEcdsa(
+    absl::Span<const absl::string_view> der_data, absl::string_view key_pem,
+    absl::string_view cert_pem, bool embed_cert,
+    std::optional<std::vector<const X509*>> extra_certs,
+    std::variant<absl::string_view, bool> signing_certificate_attribute,
+    bool use_wrong_e_content_type) {
+  auto keyset_handle = LoadEcdsaPrivateKeyFromPem(key_pem);
+  ABSL_CHECK_OK(keyset_handle);
+  auto signer = (*keyset_handle)
+                    ->GetPrimitive<crypto::tink::PublicKeySign>(
+                        crypto::tink::ConfigSignatureV0());
+  ABSL_CHECK_OK(signer);
+  std::unique_ptr<crypto::tink::PublicKeySign> signer_ptr = *std::move(signer);
+
+  bssl::UniquePtr<EVP_PKEY> key(PemToKey(key_pem));
+  ABSL_CHECK(key != nullptr);
+  bssl::UniquePtr<X509> cert(PemToCertificate(cert_pem));
+  ABSL_CHECK(cert != nullptr);
+  std::string extra_attrs;
+  if (std::holds_alternative<absl::string_view>(
+          signing_certificate_attribute)) {
+    extra_attrs = std::get<absl::string_view>(signing_certificate_attribute);
+  } else {
+    if (std::get<bool>(signing_certificate_attribute)) {
+      std::vector<X509*> certs;
+      certs.push_back(cert.get());
+      if (extra_certs.has_value()) {
+        for (const X509* extra_cert : *extra_certs) {
+          certs.push_back(const_cast<X509*>(extra_cert));
+        }
+      }
+      extra_attrs = CreateSigningCertificateV2Attribute(absl::MakeSpan(certs),
+                                                        HashAlgorithm::kSha256);
+    }
+  }
+
+  std::vector<const X509*> ca_certs;
+  if (extra_certs.has_value()) {
+    ca_certs = *extra_certs;
+  }
+
+  auto cms_or = credentio_testing::CreateSignedCms(
+      der_data, *cert,
+      [&signer_ptr](
+          absl::string_view data) -> absl::StatusOr<std::vector<uint8_t>> {
+        ABSL_ASSIGN_OR_RETURN(auto signature, signer_ptr->Sign(data));
+        return std::vector<uint8_t>(signature.begin(), signature.end());
+      },
+      {.signing_time = absl::FromUnixSeconds(1427738483),
+       .include_user_certificate = embed_cert,
+       .ca_certificates = ca_certs,
+       .extra_signed_attributes = extra_attrs,
+       .is_time_stamp_token = !use_wrong_e_content_type});
+  ABSL_CHECK_OK(cms_or.status());
+  return *cms_or;
+}
+
+std::string CreateTstInfo(absl::string_view gen_time,
+                          absl::string_view message_imprint_hash,
+                          absl::string_view message_imprint_algorithm_txt,
+                          std::optional<absl::string_view> nonce) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 30));
+
+  CBB sequence_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &sequence_cbb, CBS_ASN1_SEQUENCE));
+  // version
+  ABSL_CHECK(CBB_add_asn1_int64(&sequence_cbb, 1));
+
+  // policy
+  CBB policy_cbb;
+  ABSL_CHECK(CBB_add_asn1(&sequence_cbb, &policy_cbb, CBS_ASN1_OBJECT));
+  ABSL_CHECK(
+      CBB_add_asn1_oid_from_text(&policy_cbb, "1.2.840.113554.4.1.72585", 24));
+  ABSL_CHECK(CBB_flush(&sequence_cbb));
+
+  // messageImprint
+  CBB mi_cbb;
+  ABSL_CHECK(CBB_add_asn1(&sequence_cbb, &mi_cbb, CBS_ASN1_SEQUENCE));
+  // messageImprint.hashAlgorithm
+  CBB algorithm_identifier_cbb;
+  ABSL_CHECK(
+      CBB_add_asn1(&mi_cbb, &algorithm_identifier_cbb, CBS_ASN1_SEQUENCE));
+  // messageImprint.hashAlgorithm.algorithm
+  CBB algorithm_cbb;
+  ABSL_CHECK(
+      CBB_add_asn1(&algorithm_identifier_cbb, &algorithm_cbb, CBS_ASN1_OBJECT));
+  ABSL_CHECK(CBB_add_asn1_oid_from_text(
+      &algorithm_cbb, message_imprint_algorithm_txt.data(),
+      message_imprint_algorithm_txt.length()));
+  ABSL_CHECK(CBB_flush(&mi_cbb));
+
+  // messageImprint.hashedMessage
+  ABSL_CHECK(CBB_add_asn1_octet_string(
+      &mi_cbb, reinterpret_cast<const uint8_t*>(message_imprint_hash.data()),
+      message_imprint_hash.length()));
+  ABSL_CHECK(CBB_flush(&sequence_cbb));
+
+  // serial_number
+  ABSL_CHECK(CBB_add_asn1_int64(&sequence_cbb, 1));
+
+  // genTime
+  CBB gen_time_cbb;
+  ABSL_CHECK(
+      CBB_add_asn1(&sequence_cbb, &gen_time_cbb, CBS_ASN1_GENERALIZEDTIME));
+  ABSL_CHECK(CBB_add_bytes(&gen_time_cbb,
+                           reinterpret_cast<const uint8_t*>(gen_time.data()),
+                           gen_time.length()));
+  ABSL_CHECK(CBB_flush(&sequence_cbb));
+
+  // accuracy (this is an empty SEQUENCE because currently code under test will
+  // never actually read it)
+  CBB accuracy_cbb;
+  ABSL_CHECK(CBB_add_asn1(&sequence_cbb, &accuracy_cbb, CBS_ASN1_SEQUENCE));
+
+  // ordering
+  ABSL_CHECK(CBB_add_asn1_bool(&sequence_cbb, true));
+
+  // nonce
+  if (nonce.has_value()) {
+    ABSL_CHECK(CBB_add_bytes(&sequence_cbb,
+                             reinterpret_cast<const uint8_t*>(nonce->data()),
+                             nonce->length()));
+  }
+
+  uint8_t* der_data;
+  size_t der_len;
+  ABSL_CHECK(CBB_finish(&cbb, &der_data, &der_len));
+
+  std::string der(der_data, der_data + der_len);
+
+  OPENSSL_free(der_data);
+  return der;
+}
+
+std::string CreateSigningCertificateV2Attribute(
+    absl::Span<X509* absl_nonnull> certs, HashAlgorithm hash_algorithm) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 1000));
+  CBB attr_seq_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &attr_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB oid_cbb;
+  ABSL_CHECK(CBB_add_asn1(&attr_seq_cbb, &oid_cbb, CBS_ASN1_OBJECT));
+  ABSL_CHECK(CBB_add_bytes(&oid_cbb, OBJ_get0_data(IdAaSigningCertificateV2()),
+                           OBJ_length(IdAaSigningCertificateV2())));
+  ABSL_CHECK(CBB_flush(&attr_seq_cbb));
+
+  CBB value_set_cbb;
+  ABSL_CHECK(CBB_add_asn1(&attr_seq_cbb, &value_set_cbb, CBS_ASN1_SET));
+  CBB value_seq_cbb;
+  ABSL_CHECK(CBB_add_asn1(&value_set_cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+  for (X509* x509 : certs) {
+    uint8_t* der_ptr = nullptr;
+    size_t der_len = i2d_X509(x509, &der_ptr);
+    bssl::UniquePtr<uint8_t> der(der_ptr);
+
+    CBS der_cbs;
+    CBS_init(&der_cbs, der_ptr, der_len);
+    auto hasher = CreateHasher(hash_algorithm);
+    ABSL_CHECK_OK(hasher);
+    (*hasher)->Update(ToStringView(der_cbs));
+    std::string hash = (*hasher)->Digest();
+    CBS hash_cbs = FromStringView(hash);
+
+    CBB certs_entry_cbb;
+    ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+    if (hash_algorithm != HashAlgorithm::kSha256) {
+      ABSL_CHECK(
+          OBJ_nid2cbb(&certs_entry_cbb, static_cast<int>(hash_algorithm)));
+    }
+    ABSL_CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs),
+                                         CBS_len(&hash_cbs)));
+    ABSL_CHECK(CBB_flush(&certs_cbb));
+  }
+
+  auto result = FinishToString(&cbb);
+  ABSL_CHECK_OK(result);
+  return *result;
+}
+
+}  // namespace credentio
diff --git a/tsp/test_helpers.h b/tsp/test_helpers.h
new file mode 100644
index 0000000..5d4590d
--- /dev/null
+++ b/tsp/test_helpers.h
@@ -0,0 +1,89 @@
+// 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_TSP_TEST_HELPERS_H_
+#define THIRD_PARTY_CREDENTIO_TSP_TEST_HELPERS_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <variant>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "openssl/base.h"
+#include "tink/public_key_sign.h"
+#include "tsp/status_codes.h"
+
+namespace credentio {
+
+// Wraps arbitrary data in a DER SEQUENCE.
+std::string WrapDerSequence(absl::string_view payload);
+
+// Encodes a DER INTEGER (with tag and length, no wrapping structure)
+std::string EncodeDerInteger(uint64_t n);
+
+std::string CreateTimeStampResp(absl::string_view ts_token);
+
+std::string CreateFailedTimeStampResp(
+    TspPkiStatus status, absl::Span<const absl::string_view> status_string = {},
+    absl::Span<const int32_t> failure_info = {});
+
+// If `signing_certificate_attribute` holds a string, it will be taken as the
+// raw attribute value. If it holds `true` the value will be generated from
+// `cert_pem`. If it holds `false`, the attribute will be omitted.
+std::string CreateTimeStampTokenRsa(
+    absl::Span<const absl::string_view> der_data, absl::string_view key_pem,
+    absl::string_view cert_pem, bool embed_cert = true,
+    std::variant<absl::string_view, bool> signing_certificate_attribute = true);
+
+// If `signing_certificate_attribute` holds a string, it will be taken as the
+// raw attribute value. If it holds `true` the value will be generated from
+// `cert_pem`. If it holds `false`, the attribute will be omitted.
+std::string CreateTimeStampTokenEcdsa(
+    absl::Span<const absl::string_view> der_data,
+    const crypto::tink::PublicKeySign& signer, absl::string_view cert_pem,
+    bool embed_cert,
+    std::variant<absl::string_view, bool> signing_certificate_attribute = true);
+
+// If `signing_certificate_attribute` holds a string, it will be taken as the
+// raw attribute value. If it holds `true` the value will be generated from
+// `cert_pem` and `extra_certs. If it holds `false`, the attribute will be
+// omitted.
+// If `use_wrong_e_content_type` is true, the eContentType will be set to
+// id-data instead of id-ct-tst-info.
+std::string CreateTimeStampTokenEcdsa(
+    absl::Span<const absl::string_view> der_data, absl::string_view key_pem,
+    absl::string_view cert_pem, bool embed_cert = true,
+    std::optional<std::vector<const X509*>> extra_certs = std::nullopt,
+    std::variant<absl::string_view, bool> signing_certificate_attribute = true,
+    bool use_wrong_e_content_type = false);
+
+// If supplied, `nonce` will be inserted directly into the DER SEQUENCE, so it
+// must contain its own headers.
+std::string CreateTstInfo(
+    absl::string_view gen_time, absl::string_view message_imprint_hash,
+    absl::string_view message_imprint_algorithm_txt,
+    std::optional<absl::string_view> nonce = std::nullopt);
+
+std::string CreateSigningCertificateV2Attribute(
+    absl::Span<X509* absl_nonnull> certs, HashAlgorithm hash_algorithm);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TSP_TEST_HELPERS_H_
diff --git a/tsp/timestamp_parsing.cc b/tsp/timestamp_parsing.cc
new file mode 100644
index 0000000..d9c7bc0
--- /dev/null
+++ b/tsp/timestamp_parsing.cc
@@ -0,0 +1,250 @@
+// 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 "tsp/timestamp_parsing.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_join.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "crypto/algorithms.h"
+#include "crypto/cbs_utils.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+#include "openssl/obj.h"
+#include "tsp/constants.h"
+#include "tsp/status_codes.h"
+
+namespace credentio {
+namespace {
+
+// See RFC 3161, section 2.4.2.
+struct FailureInfo {
+  int value;
+  absl::string_view description;
+};
+
+constexpr FailureInfo kFailureInfo[] = {
+    {0, R"((badAlg) unrecognized or unsupported Algorithm Identifier)"},
+    {2, R"((badRequest) transaction not permitted or supported)"},
+    {5, R"((badDataFormat) the data submitted has the wrong format)"},
+    {14, R"((timeNotAvailable) the TSA's time source is not available)"},
+    {15,
+     R"((unacceptedPolicy) the requested TSA policy is not supported by the TSA)"},
+    {16,
+     R"((unacceptedExtension) the requested extension is not supported by the TSA)"},
+    {17,
+     R"((addInfoNotAvailable) the additional information requested could not be understood or is not available)"},
+    {25,
+     R"((systemFailure) the request cannot be handled due to system failure)"},
+};
+
+// Converts a CBS containing a FailureInfo bitstring to a human-readable string.
+std::string FailureInfoString(const CBS& failure_info) {
+  std::vector<absl::string_view> elements;
+  for (const auto& info : kFailureInfo) {
+    if (CBS_asn1_bitstring_has_bit(&failure_info, info.value)) {
+      elements.push_back(info.description);
+    }
+  }
+  return absl::StrJoin(elements, "; ");
+}
+
+}  // namespace
+
+absl::StatusOr<absl::string_view> ParseTimestampResp(
+    absl::string_view timestamp_resp) {
+  CBS timestamp_resp_cbs;
+  CBS_init(&timestamp_resp_cbs,
+           reinterpret_cast<const uint8_t*>(timestamp_resp.data()),
+           timestamp_resp.length());
+  CBS seq;
+  if (!CBS_get_asn1(&timestamp_resp_cbs, &seq, CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError(
+        "cannot parse top-level DER SEQUENCE while parsing TimeStampResp");
+  }
+  CBS pki_status_info;
+  if (!CBS_get_asn1(&seq, &pki_status_info, CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError("cannot parse TimeStampResp.status");
+  }
+
+  int64_t raw_status_code;
+  if (!CBS_get_asn1_int64(&pki_status_info, &raw_status_code)) {
+    return absl::InvalidArgumentError(
+        "cannot parse TimeStampResp.status.status");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(TspPkiStatus status_code,
+                        GetTspPkiStatus(raw_status_code));
+
+  if (!IsTspPkiStatusOk(status_code)) {
+    CBS status_string;
+    std::string status_string_joined;
+    if (CBS_peek_asn1_tag(&pki_status_info, CBS_ASN1_SEQUENCE) &&
+        CBS_get_asn1(&pki_status_info, &status_string, CBS_ASN1_SEQUENCE)) {
+      CBS status_string_element;
+      std::vector<std::string> status_string_elements;
+      while (CBS_get_asn1(&status_string, &status_string_element,
+                          CBS_ASN1_UTF8STRING)) {
+        status_string_elements.push_back(std::string(
+            reinterpret_cast<const char*>(CBS_data(&status_string_element)),
+            CBS_len(&status_string_element)));
+      }
+      status_string_joined = absl::StrJoin(status_string_elements, "; ");
+    }
+
+    CBS failure_info;
+    std::string failure_info_string;
+    if (CBS_get_asn1(&pki_status_info, &failure_info, CBS_ASN1_BITSTRING)) {
+      failure_info_string = FailureInfoString(failure_info);
+    }
+
+    std::string error_message = absl::StrCat(
+        "TimestampResp.status contains an error; status=", status_code);
+    if (!failure_info_string.empty()) {
+      absl::StrAppend(&error_message, "; ", failure_info_string);
+    }
+    if (!status_string_joined.empty()) {
+      absl::StrAppend(&error_message, "; ", status_string_joined);
+    }
+    return absl::InvalidArgumentError(error_message);
+  }
+  // We extract the entire TimeStampToken element (including the header
+  // bytes) because the CMS library expects a complete DER construct.
+  CBS ts_token;
+  if (!CBS_get_asn1_element(&seq, &ts_token, CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError(
+        "cannot extract TimeStampToken from TimeStampResp");
+  }
+  return absl::string_view(reinterpret_cast<const char*>(ts_token.data),
+                           ts_token.len);
+}
+
+absl::Status ParseTstInfo(CBS cbs, absl::Time* time,
+                          std::string* message_imprint_hash,
+                          HashAlgorithm* hash_algorithm, std::string* nonce) {
+  CBS sequence_cbs;
+  if (!CBS_get_asn1(&cbs, /*out=*/&sequence_cbs, CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError("could not read top-level sequence");
+  }
+  if (!CBS_get_asn1(&sequence_cbs, /*out=*/nullptr, CBS_ASN1_INTEGER)) {
+    return absl::InvalidArgumentError("could not skip `version`");
+  }
+  if (!CBS_get_asn1(&sequence_cbs, /*out=*/nullptr, CBS_ASN1_OBJECT)) {
+    return absl::InvalidArgumentError("could not skip `policy`");
+  }
+
+  // Format of MessageImprint defined by RFC 3161 section 2.4.1
+  CBS messageimprint_cbs;
+  if (!CBS_get_asn1(&sequence_cbs, /*out=*/&messageimprint_cbs,
+                    CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError("could not read `messageImprint`");
+  }
+
+  // Format of AlgorithmIdentifier defined by RFC 5280 section 4.1.1.2
+  CBS algorithm_identifier_cbs;
+  if (!CBS_get_asn1(&messageimprint_cbs, /*out=*/&algorithm_identifier_cbs,
+                    CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError(
+        "could not read `messageImprint.hashAlgorithm`");
+  }
+
+  CBS oid_cbs;
+  if (!CBS_get_asn1(&algorithm_identifier_cbs, /*out=*/&oid_cbs,
+                    CBS_ASN1_OBJECT)) {
+    return absl::InvalidArgumentError(
+        "could not read `messageImprint.hashAlgorithm.algorithm`");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(
+      *hash_algorithm, NidToHashAlgorithm(OBJ_cbs2nid(&oid_cbs)),
+      _.SetPrepend() << "unsupported message imprint hash algorithm: ");
+
+  CBS hash_cbs;
+  if (!CBS_get_asn1(&messageimprint_cbs, &hash_cbs, CBS_ASN1_OCTETSTRING)) {
+    return absl::InvalidArgumentError(
+        "could not read `messageImprint.hashedMessage`");
+  }
+
+  *message_imprint_hash = ToStringView(hash_cbs);
+
+  if (!CBS_get_asn1(&sequence_cbs, /*out=*/nullptr, CBS_ASN1_INTEGER)) {
+    return absl::InvalidArgumentError("could not skip `serialNumber`");
+  }
+
+  // Note that we can't use the OpenSSL function to decode the time value
+  // because it enforces the limits imposed in RFC 5280, which is more
+  // restrictive than RFC 3161.
+  CBS gentime_cbs;
+  if (!CBS_get_asn1(&sequence_cbs, /*out=*/&gentime_cbs,
+                    CBS_ASN1_GENERALIZEDTIME)) {
+    return absl::InvalidArgumentError("could not read `genTime`");
+  }
+  absl::string_view gentime_view(
+      reinterpret_cast<const char*>(gentime_cbs.data), gentime_cbs.len);
+  if (!absl::EndsWith(gentime_view, "Z")) {
+    return absl::InvalidArgumentError(
+        "`genTime` value is not specified in UTC (needs \"Z\" suffix)");
+  }
+  if (gentime_view.find_first_of('Z') != gentime_view.length() - 1) {
+    return absl::InvalidArgumentError(
+        "`genTime` is not a valid timestamp (contains \"Z\" that is not at "
+        "end)");
+  }
+  std::string error;
+  if (!absl::ParseTime(kGenTimeFormat, gentime_view, time, &error)) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("could not parse `genTime` value: ", error));
+  }
+
+  // Skip over `accuracy` if exists.
+  if (CBS_peek_asn1_tag(&sequence_cbs, CBS_ASN1_SEQUENCE)) {
+    if (!CBS_get_asn1(&sequence_cbs, /*out=*/nullptr, CBS_ASN1_SEQUENCE)) {
+      return absl::InvalidArgumentError("could not skip `accuracy`");
+    }
+  }
+  // Skip over `ordering` if exists.
+  if (CBS_peek_asn1_tag(&sequence_cbs, CBS_ASN1_BOOLEAN)) {
+    int ordering;
+    if (!CBS_get_asn1_bool(&sequence_cbs, &ordering)) {
+      return absl::InvalidArgumentError("could not skip `ordering`");
+    }
+  }
+
+  // Retrieve `nonce` if exists.
+  if (CBS_peek_asn1_tag(&sequence_cbs, CBS_ASN1_INTEGER)) {
+    CBS nonce_cbs;
+    if (CBS_get_asn1_element(&sequence_cbs, &nonce_cbs, CBS_ASN1_INTEGER)) {
+      *nonce = std::string(reinterpret_cast<const char*>(CBS_data(&nonce_cbs)),
+                           CBS_len(&nonce_cbs));
+    } else {
+      return absl::InvalidArgumentError("could not read `nonce`");
+    }
+  } else {
+    nonce->clear();
+  }
+
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/tsp/timestamp_parsing.h b/tsp/timestamp_parsing.h
new file mode 100644
index 0000000..fbbe90e
--- /dev/null
+++ b/tsp/timestamp_parsing.h
@@ -0,0 +1,50 @@
+// 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_TSP_TIMESTAMP_PARSING_H_
+#define THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_PARSING_H_
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "crypto/algorithms.h"
+#include "openssl/base.h"
+
+namespace credentio {
+
+// Pulls the CMS timestamp token out of TimeStampResp and checks the response's
+// status. See RFC 3161 section 2.4.2.
+// `timestamp_resp` must be the full DER SEQUENCE, including the header bytes.
+// Any TSA status codes other than "granted" or "grantedWithMods" in the
+// response's `status` field will result in INVALID_ARGUMENT (as will any DER
+// parsing errors).
+absl::StatusOr<absl::string_view> ParseTimestampResp(
+    absl::string_view timestamp_resp);
+
+// Extracts the `messageImprint`, `genTime`, and `nonce` values from a `TSTInfo`
+// DER sequence, defined by RFC 3161. The array backing `cbs` will not be
+// modified. All errors can be treated as `timestamp.malformed`. If the TSTInfo
+// does not contain a `nonce` value, the provided `nonce` string will be left
+// empty.
+absl::Status ParseTstInfo(CBS cbs, absl::Time* time,
+                          std::string* message_imprint_hash,
+                          HashAlgorithm* hash_algorithm, std::string* nonce);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_PARSING_H_
diff --git a/tsp/timestamp_parsing_test.cc b/tsp/timestamp_parsing_test.cc
new file mode 100644
index 0000000..46c985e
--- /dev/null
+++ b/tsp/timestamp_parsing_test.cc
@@ -0,0 +1,338 @@
+// 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 "tsp/timestamp_parsing.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+
+#include "absl/base/no_destructor.h"
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "crypto/algorithms.h"
+#include "crypto/cbs_utils.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+#include "openssl/mem.h"  // IWYU pragma: keep, `bssl::UniquePtr` in base.h is incomplete
+#include "testing/test_file_utils.h"
+#include "tsp/constants.h"
+#include "tsp/status_codes.h"
+#include "tsp/test_helpers.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::Test;
+using ::testing::TestWithParam;
+using ::testing::ValuesIn;
+
+constexpr absl::string_view kSha384OidTxt = "2.16.840.1.101.3.4.2.2";
+constexpr absl::string_view kSha256OidTxt = "2.16.840.1.101.3.4.2.1";
+
+constexpr absl::string_view kMessageImprintHash = "fake message imprint";
+
+constexpr absl::string_view kTestKeysDir = "c2pa/testing/testdata/keys/";
+
+absl::string_view ValidNonce() {
+  static absl::NoDestructor<std::string> nonce(EncodeDerInteger(0xf00dbabe));
+  return *nonce;
+}
+
+std::string LoadTestCryptoAsset(absl::string_view short_path) {
+  std::string file_path = absl::StrCat(kTestKeysDir, short_path);
+  auto contents = credentio_testing::GetContents(file_path);
+  CHECK_OK(contents.status());
+  return *contents;
+}
+
+absl::Time ParseGenTime(absl::string_view timestamp) {
+  absl::Time time;
+  std::string parse_error;
+  CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error))
+      << "ParseTime(\"" << kGenTimeFormat << "\", \"" << timestamp
+      << "\"): " << parse_error;
+  return time;
+}
+
+class ParseTimestampRespTest : public Test {
+ public:
+  static void SetUpTestSuite() {
+    *time_stamp_token_ = CreateTimeStampTokenEcdsa(
+        {"NOT DER NOT ANYTHING WHATEVER DO NOT CARE THIS IS JUST JUNK FOR "
+         "TESTING"},
+        LoadTestCryptoAsset("test_ca/tsa.key"),
+        LoadTestCryptoAsset("test_ca/tsa.pem"));
+  }
+
+ protected:
+  absl::string_view time_stamp_token() { return *time_stamp_token_; }
+
+ private:
+  static inline absl::NoDestructor<std::string> time_stamp_token_;
+};
+
+TEST_F(ParseTimestampRespTest, ValidTimestampResp) {
+  std::string timestamp_resp = CreateTimeStampResp(time_stamp_token());
+
+  EXPECT_THAT(ParseTimestampResp(timestamp_resp),
+              IsOkAndHolds(time_stamp_token()));
+}
+
+TEST_F(ParseTimestampRespTest, FailedRespWithNoExplanation) {
+  std::string timestamp_resp =
+      CreateFailedTimeStampResp(TspPkiStatus::kRejection);
+
+  EXPECT_THAT(
+      ParseTimestampResp(timestamp_resp),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               "TimestampResp.status contains an error; status=rejection"));
+}
+
+TEST_F(ParseTimestampRespTest, FailedRespWithStatusString) {
+  std::string timestamp_resp = CreateFailedTimeStampResp(
+      TspPkiStatus::kRejection,
+      /*status_string=*/{"some explanation", "some other explanation"});
+
+  EXPECT_THAT(
+      ParseTimestampResp(timestamp_resp),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          R"(TimestampResp.status contains an error; status=rejection; some explanation; some other explanation)"));
+}
+
+TEST_F(ParseTimestampRespTest, FailedRespWithFailureInfo) {
+  std::string timestamp_resp = CreateFailedTimeStampResp(
+      TspPkiStatus::kRejection, {},
+      {5 /* badDataFormat */, 14 /* timeNotAvailable */});
+  EXPECT_THAT(
+      ParseTimestampResp(timestamp_resp),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          R"(TimestampResp.status contains an error; status=rejection; (badDataFormat) the data submitted has the wrong format; (timeNotAvailable) the TSA's time source is not available)"));
+}
+
+TEST(ParseTimeStampRespTest, NoSequence) {
+  constexpr absl::string_view kNotSequence = "invalid";
+
+  EXPECT_THAT(
+      ParseTimestampResp(kNotSequence),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          "cannot parse top-level DER SEQUENCE while parsing TimeStampResp"));
+}
+
+TEST(ParseTimeStampRespTest, InvalidPkiStatusInfo) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1024));
+  CBB req;
+  CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE));
+  CHECK(CBB_add_bytes(&req, reinterpret_cast<const uint8_t*>("12345"), 5));
+  uint8_t* cbb_data;
+  size_t cbb_len;
+  CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
+  bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
+
+  EXPECT_THAT(ParseTimestampResp(absl::string_view(
+                  reinterpret_cast<char*>(cbb_data), cbb_len)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "cannot parse TimeStampResp.status"));
+}
+
+TEST(ParseTimeStampRespTest, InvalidStatusCode) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1024));
+  CBB req;
+  CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE));
+  CBB pki_status_info;
+  CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE));
+  CHECK(CBB_add_bytes(&pki_status_info,
+                      reinterpret_cast<const uint8_t*>("12345"), 5));
+  uint8_t* cbb_data;
+  size_t cbb_len;
+  CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
+  bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
+
+  EXPECT_THAT(ParseTimestampResp(absl::string_view(
+                  reinterpret_cast<char*>(cbb_data), cbb_len)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "cannot parse TimeStampResp.status.status"));
+}
+
+TEST(ParseTimeStampRespTest, InvalidTimestampToken) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1024));
+  CBB req;
+  CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE));
+  CBB pki_status_info;
+  CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE));
+  CHECK(CBB_add_asn1_int64(
+      &pki_status_info, static_cast<int64_t>(TspPkiStatus::kGrantedWithMods)));
+  CHECK(CBB_add_bytes(&req, reinterpret_cast<const uint8_t*>("12345"), 5));
+  uint8_t* cbb_data;
+  size_t cbb_len;
+  CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
+  bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
+
+  EXPECT_THAT(ParseTimestampResp(absl::string_view(
+                  reinterpret_cast<char*>(cbb_data), cbb_len)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "cannot extract TimeStampToken from TimeStampResp"));
+}
+
+struct InvalidTimeFormatTestCase {
+  absl::string_view header_key;
+  absl::string_view timestamp;
+  absl::string_view expected_error_substr;
+};
+
+constexpr InvalidTimeFormatTestCase kInvalidFormatTestCases[] = {
+    {.timestamp = "6666666666666Z",
+     .expected_error_substr = "could not parse `genTime` value"},
+    {.timestamp = "20240815094500.9999+0000Z",
+     .expected_error_substr = "could not parse `genTime` value"},
+    {.timestamp = "20200521000000Z19920521000000Z19980521000000Z",
+     .expected_error_substr =
+         R"(`genTime` is not a valid timestamp (contains "Z" that is not at end))"},
+    {.timestamp = "66666666666666",
+     .expected_error_substr =
+         "`genTime` value is not specified in UTC (needs \"Z\" suffix)"},
+    {.timestamp = "6666666666666Z",
+     .expected_error_substr = "could not parse `genTime` value"},
+    {.timestamp = "20240815094500.9999+0000Z",
+     .expected_error_substr = "could not parse `genTime` value"},
+    {.timestamp = "20200521000000Z19920521000000Z19980521000000Z",
+     .expected_error_substr =
+         R"(`genTime` is not a valid timestamp (contains "Z" that is not at end))"},
+    {.timestamp = "66666666666666",
+     .expected_error_substr =
+         "`genTime` value is not specified in UTC (needs \"Z\" suffix)"},
+};
+
+using InvalidTimeFormatTest = TestWithParam<InvalidTimeFormatTestCase>;
+
+TEST_P(InvalidTimeFormatTest, RejectsInvalidFormats) {
+  std::string tst_info =
+      CreateTstInfo(GetParam().timestamp, kMessageImprintHash, kSha256OidTxt);
+  absl::Time time;
+  std::string message_imprint_hash;
+  HashAlgorithm hash_algorithm;
+  std::string nonce;
+
+  EXPECT_THAT(ParseTstInfo(FromStringView(tst_info), &time,
+                           &message_imprint_hash, &hash_algorithm, &nonce),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       testing::HasSubstr(GetParam().expected_error_substr)));
+}
+
+INSTANTIATE_TEST_SUITE_P(ParseTstInfoTest, InvalidTimeFormatTest,
+                         ValuesIn(kInvalidFormatTestCases));
+
+struct ValidTimeFormatTestCase {
+  absl::string_view timestamp;
+};
+
+constexpr ValidTimeFormatTestCase kValidTimestampFormatTestCases[] = {
+    {.timestamp = "20230521091011.1337Z"},
+    {.timestamp = "20230521000000Z"},
+    {.timestamp = "20240101000000Z"},
+    {.timestamp = "20251231235959.9999Z"}};
+
+using ValidTimeFormatTest = TestWithParam<ValidTimeFormatTestCase>;
+
+TEST_P(ValidTimeFormatTest, AcceptsValidFormats) {
+  std::string tst_info = CreateTstInfo(
+      GetParam().timestamp, kMessageImprintHash, kSha384OidTxt, ValidNonce());
+
+  absl::Time time;
+  std::string message_imprint_hash;
+  HashAlgorithm hash_algorithm;
+  std::string nonce;
+
+  EXPECT_THAT(ParseTstInfo(FromStringView(tst_info), &time,
+                           &message_imprint_hash, &hash_algorithm, &nonce),
+              IsOk());
+  EXPECT_EQ(time, ParseGenTime(GetParam().timestamp));
+  EXPECT_EQ(message_imprint_hash, kMessageImprintHash);
+  EXPECT_EQ(hash_algorithm, HashAlgorithm::kSha384);
+  EXPECT_EQ(nonce, ValidNonce());
+}
+
+INSTANTIATE_TEST_SUITE_P(ParseTstInfoTest, ValidTimeFormatTest,
+                         ValuesIn(kValidTimestampFormatTestCases));
+
+TEST(ParseTstInfoTest, InvalidVersion) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1024));
+  CBB tst_info;
+  CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE));
+  // Version - should be INTEGER
+  CHECK(CBB_add_asn1_octet_string(&tst_info, nullptr, 0));
+
+  uint8_t* cbb_data;
+  size_t cbb_len;
+  CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
+  bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
+
+  absl::Time time;
+  std::string message_imprint_hash;
+  HashAlgorithm hash_algorithm;
+  std::string nonce;
+
+  EXPECT_THAT(
+      ParseTstInfo(FromStringView(absl::string_view(
+                       reinterpret_cast<char*>(cbb_data), cbb_len)),
+                   &time, &message_imprint_hash, &hash_algorithm, &nonce),
+      StatusIs(absl::StatusCode::kInvalidArgument, "could not skip `version`"));
+}
+
+TEST(ParseTstInfoTest, InvalidPolicy) {
+  CBB cbb;
+  CHECK(CBB_init(&cbb, 1024));
+  CBB tst_info;
+  CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE));
+  // Version
+  CHECK(CBB_add_asn1_int64(&tst_info, 1));
+  // Policy - should be OBJECT IDENTIFIER
+  CHECK(CBB_add_asn1_int64(&tst_info, 12345));
+
+  uint8_t* cbb_data;
+  size_t cbb_len;
+  CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
+  bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
+
+  absl::Time time;
+  std::string message_imprint_hash;
+  HashAlgorithm hash_algorithm;
+  std::string nonce;
+
+  EXPECT_THAT(
+      ParseTstInfo(FromStringView(absl::string_view(
+                       reinterpret_cast<char*>(cbb_data), cbb_len)),
+                   &time, &message_imprint_hash, &hash_algorithm, &nonce),
+      StatusIs(absl::StatusCode::kInvalidArgument, "could not skip `policy`"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/tsp/timestamp_verifier.cc b/tsp/timestamp_verifier.cc
new file mode 100644
index 0000000..86df41b
--- /dev/null
+++ b/tsp/timestamp_verifier.cc
@@ -0,0 +1,263 @@
+// 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 "tsp/timestamp_verifier.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_log.h"
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "openssl/asn1.h"
+#include "openssl/base.h"
+#include "openssl/bytestring.h"
+#include "openssl/mem.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/pki/verify.h"
+#include "openssl/pki/verify_error.h"
+#include "openssl/stack.h"
+#include "openssl/x509.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/timestamp_parsing.h"
+#include "tsp/verified_timestamp.h"
+namespace credentio {
+
+namespace {
+
+// Represents the `EssCertIdV2` SEQUENCE, part of the `SigningCertificateV2`
+// attribute as defined in RFC 5035.
+struct EssCertIdV2 {
+  HashAlgorithm hash_algorithm;
+  std::string cert_hash;
+};
+
+absl::Status CertMatchesEssCertId(absl::string_view cert, EssCertIdV2 id) {
+  auto hasher = CreateHasher(id.hash_algorithm);
+  if (!hasher.ok()) {
+    return absl::Status(
+        absl::StatusCode::kFailedPrecondition,
+        absl::StrCat("Invalid HashAlgorithm value: ", id.hash_algorithm, "; ",
+                     hasher.status().message()));
+  }
+  (*hasher)->Update(cert);
+  if ((*hasher)->Digest() == id.cert_hash) {
+    return absl::OkStatus();
+  } else {
+    return absl::UnauthenticatedError(
+        R"(certificate does not match EssCertIDV2 value)");
+  }
+}
+
+absl::Status CheckTsaCertificateTrust(
+    const bssl::VerifyTrustStore& tsa_roots,
+    const VerifiedTimestamp& verified_timestamp,
+    absl::Span<const EssCertIdV2> ess_cert_ids) {
+  if (ess_cert_ids.empty()) {
+    return absl::InvalidArgumentError(
+        R"(SigningCertificateV2 signed attribute is empty; at minimum, TSAs must record their own leaf certificate in this attribute)");
+  }
+  absl::Status status = CertMatchesEssCertId(
+      verified_timestamp.tsa_certificate(), ess_cert_ids[0]);
+  if (!status.ok()) {
+    return absl::Status(
+        status.code(),
+        absl::StrCat("TSA cert does not match SigningCertificateV2 attribute: ",
+                     status.message()));
+  }
+
+  auto intermediate_certs_der =
+      verified_timestamp.certificate_chain().subspan(1);
+  if (intermediate_certs_der.size() > 2) {
+    return absl::UnauthenticatedError(
+        absl::StrCat("excessive timestamp certificate chain length: ",
+                     intermediate_certs_der.size() + 1));
+  }
+
+  bssl::CertificateVerifyOptions opts;
+
+  opts.key_purpose =
+      bssl::CertificateVerifyOptions::KeyPurpose::C2PA_TIMESTAMPING;
+  opts.leaf_cert = verified_timestamp.tsa_certificate();
+  opts.intermediates.insert(opts.intermediates.end(),
+                            intermediate_certs_der.begin(),
+                            intermediate_certs_der.end());
+  // Timestamp cert chain validity periods are evaluated with respect to the
+  // attested time, per
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_validate_the_time_stamp:
+  // "Validate that the attested time, as found in the genTime field (in the
+  // timeStampToken), falls within the validity period of the TSA’s signing
+  // certificate."
+  opts.time = absl::ToTimeT(verified_timestamp.asserted_time());
+  opts.trust_store = &tsa_roots;
+  bssl::VerifyError error;
+  if (!bssl::CertificateVerify(opts, &error)) {
+    if (error.Code() == bssl::VerifyError::StatusCode::CERTIFICATE_EXPIRED ||
+        error.Code() ==
+            bssl::VerifyError::StatusCode::CERTIFICATE_NOT_YET_VALID) {
+      return absl::OutOfRangeError(error.DiagnosticString());
+    }
+    return absl::UnauthenticatedError(
+        absl::StrCat("timestamp certificate chain could not be validated: ",
+                     error.DiagnosticString()));
+  }
+  return absl::OkStatus();
+}
+
+// Extracts the `certs` field of the `SigningCertificateV2` attribute (attribute
+// defined in RFC 5035).
+absl::StatusOr<std::vector<EssCertIdV2>> GetEssCertIds(
+    absl::string_view signing_certificate_v2_bytes) {
+  std::vector<EssCertIdV2> out;
+  CBS signing_certificate_cbs;
+  CBS_init(
+      &signing_certificate_cbs,
+      reinterpret_cast<const uint8_t*>(signing_certificate_v2_bytes.data()),
+      signing_certificate_v2_bytes.size());
+
+  CBS signing_certificate_sequence;
+  if (!CBS_get_asn1(&signing_certificate_cbs, &signing_certificate_sequence,
+                    CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError(
+        R"(could not parse DER SEQUENCE for SigningCertificateV2 attribute value)");
+  }
+
+  CBS certs_sequence;
+  if (!CBS_get_asn1(&signing_certificate_sequence, &certs_sequence,
+                    CBS_ASN1_SEQUENCE)) {
+    return absl::InvalidArgumentError(
+        R"(could not parse `certs` field (DER SEQUENCE) of SigningCertificateV2 attribute value)");
+  }
+
+  CBS ess_cert_id_cbs;
+  while (CBS_get_asn1(&certs_sequence, &ess_cert_id_cbs, CBS_ASN1_SEQUENCE)) {
+    // The hash algorithm is an optional field, if it is not present it defaults
+    // to SHA-256.
+    int algorithm_nid = NID_sha256;
+    CBS original = ess_cert_id_cbs;
+    CBS oid_cbs;
+    if (CBS_get_asn1(&ess_cert_id_cbs, &oid_cbs, CBS_ASN1_OBJECT)) {
+      algorithm_nid = OBJ_cbs2nid(&oid_cbs);
+      if (algorithm_nid == NID_undef) {
+        bssl::UniquePtr<char> oid_txt_uniq(CBS_asn1_oid_to_text(&oid_cbs));
+        absl::string_view oid_txt;
+        if (oid_txt_uniq != nullptr) {
+          oid_txt = oid_txt_uniq.get();
+        } else {
+          oid_txt = "<unprintable OID>";
+        }
+        return absl::InvalidArgumentError(absl::StrCat(
+            R"(unrecognized OID in `certs.hashAlgorithm` field of SigningCertificateV2 attribute value; OID value: )",
+            oid_txt));
+      }
+    } else {
+      ess_cert_id_cbs = original;
+    }
+    auto hash_algorithm = NidToHashAlgorithm(algorithm_nid);
+    if (!hash_algorithm.ok()) {
+      return absl::Status(
+          hash_algorithm.status().code(),
+          absl::StrCat(
+              "unsupported hash algorithm in `certs.hashAlgorithm` field of "
+              "SigningCertificateV2 attribute value: ",
+              hash_algorithm.status().message()));
+    }
+
+    CBS hash_cbs;
+    if (!CBS_get_asn1(&ess_cert_id_cbs, &hash_cbs, CBS_ASN1_OCTETSTRING)) {
+      return absl::InvalidArgumentError(
+          R"(could not parse `certs.hash` field (DER OCTETSTRING) of SigningCertificateV2 attribute value)");
+    }
+    out.push_back(EssCertIdV2{
+        .hash_algorithm = *hash_algorithm,
+        .cert_hash =
+            std::string(reinterpret_cast<const char*>(CBS_data(&hash_cbs)),
+                        CBS_len(&hash_cbs))});
+  }
+
+  return out;
+}
+
+}  // namespace
+
+absl::StatusOr<VerifiedTimestamp> TimestampVerifier::VerifyTimestampToken(
+    const ParsedTimestampToken& parsed_token) const {
+  ABSL_ASSIGN_OR_RETURN(auto chain, parsed_token.GetCertificateChain());
+
+  // CMS supports more digest algorithms than C2PA, so we need to check that the
+  // digest algorithm is supported.
+  ABSL_RETURN_IF_ERROR(parsed_token.GetMessageImprintHashAlgorithm().status());
+
+  if (chain.empty()) {
+    // This would indicate a bug in the CMS library.
+    return absl::InternalError("TSA leaf cert missing");
+  }
+
+  absl::Status verify_status = parsed_token.VerifySignature();
+  if (!verify_status.ok()) {
+    return absl::UnauthenticatedError(
+        absl::StrCat("the timestamp signature could not be verified: ",
+                     verify_status.message()));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto signing_certificate_v2_bytes,
+                        parsed_token.GetSigningCertificateV2Bytes());
+
+  ABSL_ASSIGN_OR_RETURN(auto ess_cert_ids,
+                        GetEssCertIds(signing_certificate_v2_bytes));
+
+  ABSL_ASSIGN_OR_RETURN(auto tst_info_bytes, parsed_token.GetTstInfoBytes());
+  CBS cbs;
+  CBS_init(&cbs, reinterpret_cast<const uint8_t*>(tst_info_bytes.data()),
+           tst_info_bytes.size());
+
+  absl::Time time;
+  std::string message_imprint_hash;
+  HashAlgorithm hash_algorithm;
+  std::string nonce;
+  ABSL_RETURN_IF_ERROR(
+      ParseTstInfo(cbs, &time, &message_imprint_hash, &hash_algorithm, &nonce));
+
+  ABSL_ASSIGN_OR_RETURN(
+      VerifiedTimestamp verified_timestamp,
+      VerifiedTimestamp::Create(time, std::move(chain),
+                                std::move(message_imprint_hash), hash_algorithm,
+                                nonce));
+
+  if (tsa_roots_ == nullptr) {
+    ABSL_LOG_EVERY_N_SEC(INFO, 60)
+        << "Bypassing C2PA TSA certificate trust check.";
+  } else {
+    ABSL_RETURN_IF_ERROR(CheckTsaCertificateTrust(
+        *tsa_roots_, verified_timestamp, ess_cert_ids));
+  }
+  ABSL_VLOG(1) << "Verified timestamp: " << verified_timestamp.asserted_time();
+  return verified_timestamp;
+}
+
+}  // namespace credentio
diff --git a/tsp/timestamp_verifier.h b/tsp/timestamp_verifier.h
new file mode 100644
index 0000000..9eaaa7e
--- /dev/null
+++ b/tsp/timestamp_verifier.h
@@ -0,0 +1,52 @@
+// 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.
+//
+
+// Support for RFC 3161 TimestampToken verification.
+#ifndef THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_VERIFIER_H_
+#define THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_VERIFIER_H_
+
+#include "absl/base/attributes.h"
+#include "absl/base/nullability.h"
+#include "absl/status/statusor.h"
+#include "openssl/pki/verify.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+
+// Verifier for RFC 3161 TimestampTokens, implemented using SimpleCMS.
+class TimestampVerifier {
+ public:
+  // If `tsa_roots` is `nullptr`, the verifier will skip trust checks (only for
+  // use in tests).
+  explicit TimestampVerifier(const bssl::VerifyTrustStore* absl_nullable
+                                 tsa_roots ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : tsa_roots_(tsa_roots) {}
+
+  // Verifies the signature of an RFC 3161 TimestampToken, and
+  // checks that the TSA is trusted. Error codes reported include
+  //   kInvalidArgument: Malformed timestamp token
+  //   kUnauthenticated: Signature verification errors, missing/untrusted certs
+  //   kOutOfRange: A certificate was not within its validity period
+  absl::StatusOr<VerifiedTimestamp> VerifyTimestampToken(
+      const ParsedTimestampToken& parsed_token) const;
+
+ private:
+  const bssl::VerifyTrustStore* tsa_roots_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TSP_TIMESTAMP_VERIFIER_H_
diff --git a/tsp/timestamp_verifier_test.cc b/tsp/timestamp_verifier_test.cc
new file mode 100644
index 0000000..001717e
--- /dev/null
+++ b/tsp/timestamp_verifier_test.cc
@@ -0,0 +1,756 @@
+// 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 "tsp/timestamp_verifier.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <iterator>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/algorithm/container.h"
+#include "absl/base/no_destructor.h"
+#include "absl/log/absl_check.h"
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "crypto/cbs_utils.h"
+#include "crypto/default/trust_store.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/base.h"
+#include "openssl/bio.h"
+#include "openssl/bytestring.h"
+#include "openssl/digest.h"
+#include "openssl/mem.h"
+#include "openssl/nid.h"
+#include "openssl/obj.h"
+#include "openssl/pem.h"
+#include "openssl/pki/verify.h"
+#include "openssl/x509.h"
+#include "proto/validation_result.pb.h"
+#include "tsp/constants.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/test_helpers.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::ElementsAre;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+using ::testing::Return;
+
+constexpr absl::string_view kCaPem = R"(-----BEGIN CERTIFICATE-----
+MIIB3zCCAWagAwIBAgIUXJz+6VKGMVOVs+xupRS3Cne3or0wCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw
+NDE1MTMyMFoYDzIxMjUwMTExMTUxMzIwWjAmMSQwIgYDVQQDDBtHb29nbGUgQzJQ
+QSBUZXN0aW5nIFJvb3QgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATWa/wOwQMD
+5zNmXVNGbPJuptbvXrNBhgKZI6SHRzngJ4bV+SShgLDqFdtnDfwAO/nxHyACTNl6
+sJ9ue407+HW6W1WEODD9a/jVYizAVkzUaEDElDEA5PPkegHqtVJuf8CjUzBRMB0G
+A1UdDgQWBBTTMOrQr2ny9gYyywPGUZGx/KENtzAfBgNVHSMEGDAWgBTTMOrQr2ny
+9gYyywPGUZGx/KENtzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQC
+MCsAxaLyEkp2nyQ2BtBoSuu52k3qfWLNAOHiaifoaxhQzDo1juNYuITM8I88PwVO
+QAIwJBRVRNysxvouMf7cQ2VSYTPc32BY/m38wnWS3K94l7eS3sANM9U++e8dgEtG
+U2Tv
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kTsaPem = R"(-----BEGIN CERTIFICATE-----
+MIIB1zCCAVygAwIBAgIUaUM3tWVucTzvVWUNY0HjFKBAEkwwCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw
+NDIxNDIxNFoYDzIxMjUwMTExMjE0MjE0WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ
+QSBUZXN0aW5nIFRTQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLlLssu8MJdD
+CS3UV4CPcI1l4lnQ7Y6V7jAaAXTt7wbRnN8lbjcMmfzY4mqhc6HMtFYqYj4UeO7t
+tjkvzI2PfGmjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEF
+BQcDCDAdBgNVHQ4EFgQUeWySrF5tKc2Cwe7AzkxALXVXyIwwHwYDVR0jBBgwFoAU
+0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaQAwZgIxAPwCKKGMymYV
+8p2gXDDyJSMbHJnfHu/FvfeaWYF6a6twCCt7IFehJWbyg/f1p/3Q7QIxAK0yLI6L
+/FTQPRaB3nZBm4Ocw9QZ171qisDKd1wW83mjmFAp9aNFlFv2yQt9A1NeEQ==
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kTsaRsaPem = R"(-----BEGIN CERTIFICATE-----
+MIIDoDCCAyegAwIBAgIUDDONK2J0GuK8OSkZYI4sN4GGJaYwCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIw
+NTAwNDU1N1oYDzIxMjUwMTEyMDA0NTU3WjAiMSAwHgYDVQQDDBdHb29nbGUgQzJQ
+QSBUZXN0aW5nIFRTQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO5N
+FYAVihiznVBVhnpoOIvz790xxqdpZXRYrCXkj9L2o07sjUza8cotpvDzd9W8EkPg
+CJSl0dJ4VeZ7wq4PH0azLgpWDppTVtUtzbYGWacvgWsgLQ96SqAmPYnuHwD+b+Wv
+q/p35JB3csFDpg2F9SKFHZH50KQJ4z+WLAKf9Gexfs0y8DsN3DtQgzaVF2vS7/SL
+jfFbnS1aHXoeKvPiu475pltkuw0velbXi8/cHIq3VuQOwxgyCIE4FuWJYYPMMu5j
+HJ0+bBtDI+Nn8rWmN7hpOV1Lo268gzwR2HtGmdM1LABj+RY8nBdyXJmfAY9kqm/D
+VuEVrsGdIOYiAw7iHLvjc4CfNaKs6l9bhTppsVydLZE1fKuMFvmSs5Gc9yIermeu
+jmoPB6QSVJh76mKOug7BmEzLHipFRxfDOmdr+eP/5w3L1ZOA+nEG2XWVICbIH/ND
+rteeFCGJsDV7wDx6Yhpfvjh8A5qP9Z41MW8CtqpzNakivtVaDKmULZ/dyKeI8BZ5
+DvEg/6mpwN6Eu06czep0tO4LQ/l3935w3d/lkpn1yKDu9nm02uvVBEqwZW2lR5BT
+MeqxQTMrrbmoi0mjsyX+5XmsUzAzBFlZ9HJi14p6vd70FJOlRXttGxCxiLC/QkmN
+krFnFH7v1bhbRGGtKqttUK4yogdnxaOhZ6gr/s45AgMBAAGjajBoMA4GA1UdDwEB
+/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAdBgNVHQ4EFgQU+rL+AIZ5
+EsQVhIN6SfX34LSTS7YwHwYDVR0jBBgwFoAU0zDq0K9p8vYGMssDxlGRsfyhDbcw
+CgYIKoZIzj0EAwIDZwAwZAIwDrCJbyS2Z4yA+82o7slKap3VpBwqBW5xrYjIJyV3
+IGzPI3i55bQb0wKRf6SMBHmCAjAfrVYauA+rpNxEYEIoM/ziaJ0xEbF/tw/JQyVE
+0JW905o/hzXHO5LV3z/x0SYRPiY=
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kTsaWithIntermediatePem =
+    R"(-----BEGIN CERTIFICATE-----
+MIIBvzCCAWSgAwIBAgIUImopbS3q7uF8Wz88f39/wfxAIYowCgYIKoZIzj0EAwIw
+LjEsMCoGA1UEAwwjR29vZ2xlIEMyUEEgVGVzdGluZyBJbnRlcm1lZGlhdGUgQ0Ew
+IBcNMjUwMzEzMTM1NzI2WhgPMjEyNTAyMTcxMzU3MjZaMCIxIDAeBgNVBAMMF0dv
+b2dsZSBDMlBBIFRlc3RpbmcgVFNBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE
+Z9YEmeJodxdGQdhJbSjIeccIbxjIQY5rPp/c6zqF4/mRr8VOdMur3r/r0Gyxju8k
+7R+6/j5IjQwzci01Qs7W06NqMGgwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM
+MAoGCCsGAQUFBwMIMB0GA1UdDgQWBBQGI2mf9lUe+oNSZE/WRQlwVGMgoTAfBgNV
+HSMEGDAWgBRmRE2zkZe58xIqg//9Iw76XyOLKjAKBggqhkjOPQQDAgNJADBGAiEA
+onFfvfflN96uF0ECnej2aZ7ikuyz+OsDEQVDQZNBTHMCIQCo5YiNm7EeeMexhDzM
+vVdDSKzUFmzJXGZAO/kkEQyNXw==
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kIntermediatePem = R"(-----BEGIN CERTIFICATE-----
+MIIByjCCAVGgAwIBAgIUVhW73Ry+Zn7eKwhnxTiyLDJ3cKowCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDMx
+MzEzMzc0MVoYDzIxMjUwMjE3MTMzNzQxWjAuMSwwKgYDVQQDDCNHb29nbGUgQzJQ
+QSBUZXN0aW5nIEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH
+A0IABJKYZuxD1F5Eqkgl+fqnubgtLr4MHlAh2p/7n8E/BOZuemrAAXVw7p27D1Oy
+n6RNyobJVRZHcm61i09hfTKw/NajUzBRMB0GA1UdDgQWBBRmRE2zkZe58xIqg//9
+Iw76XyOLKjAfBgNVHSMEGDAWgBTTMOrQr2ny9gYyywPGUZGx/KENtzAPBgNVHRMB
+Af8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQCMBqHAcfcOv21PmRMWchgZ37ZRoor
+4+XlKPnh+L0gBBRykqI49VvvpgxtCC2jVA2BnQIwZf0DOnk6c4lUvSzrEJ7+ToR1
+dJMoNuufOTVoMQdojCLwnH4GrwID0qsdXJm8X0XY
+-----END CERTIFICATE-----)";
+
+constexpr absl::string_view kClaimSignerPem = R"(-----BEGIN CERTIFICATE-----
+MIICDzCCAZWgAwIBAgIUR8DAqLjMOExo0KJS3DGC4Qsy79kwCgYIKoZIzj0EAwIw
+JjEkMCIGA1UEAwwbR29vZ2xlIEMyUEEgVGVzdGluZyBSb290IENBMCAXDTI1MDIy
+MzA1MDMwMFoYDzIxMjUwMTMwMDUwMzAwWjBbMSYwJAYDVQQDDB1Hb29nbGUgVGVz
+dCBDMlBBIENsYWltIFNpZ25lcjEdMBsGA1UECgwUR29vZ2xlIEMyUEEgVGVzdCBP
+cmcxEjAQBgNVBAsMCVRlc3QgVW5pdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA
+BKw1/4qPpbSgEWNUy7B5R48dxnsx92d5lqh7HY204TpvmxQgukcwavpW8XBxSdYr
+F+XEgNStkprWpnkoKWNpShyjajBoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
+DDAKBggrBgEFBQcDBDAdBgNVHQ4EFgQU6wle6tElLcBSIwsoBICb7gInPiQwHwYD
+VR0jBBgwFoAU0zDq0K9p8vYGMssDxlGRsfyhDbcwCgYIKoZIzj0EAwIDaAAwZQIw
+YNgGpaaZxI2km45t/Wd+njvQlF0JJ4+9GvZ0bDQWH7kjAQTmKrkxuNMT77cA2IMW
+AjEArZqFNmE/5yv8tzHbPP73c3n8BPUmjIpZR5A2nzjfhPzpClFp70lkO+mTu82d
+ULnv
+-----END CERTIFICATE-----)";
+
+class MockParsedTimestampToken : public ParsedTimestampToken {
+ public:
+  MOCK_METHOD(absl::Status, VerifySignature, (), (const, override));
+  MOCK_METHOD(absl::StatusOr<HashAlgorithm>, GetMessageImprintHashAlgorithm, (),
+              (const, override));
+  MOCK_METHOD(absl::StatusOr<std::vector<std::string>>, GetCertificateChain, (),
+              (const, override));
+  MOCK_METHOD(absl::StatusOr<absl::string_view>, GetSigningCertificateV2Bytes,
+              (), (const, override));
+  MOCK_METHOD(absl::StatusOr<absl::string_view>, GetTstInfoBytes, (),
+              (const, override));
+};
+
+std::string GetSigningCertificateV2Value(absl::string_view attribute_bytes) {
+  CBS cbs = FromStringView(attribute_bytes);
+  CBS seq, oid, set;
+  ABSL_CHECK(CBS_get_asn1(&cbs, &seq, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(CBS_get_asn1(&seq, &oid, CBS_ASN1_OBJECT));
+  ABSL_CHECK(CBS_get_asn1(&seq, &set, CBS_ASN1_SET));
+  CBS val_elem;
+  ABSL_CHECK(CBS_get_any_asn1_element(&set, &val_elem, nullptr, nullptr));
+  return std::string(ToStringView(val_elem));
+}
+
+constexpr absl::string_view kSha384OidTxt = "2.16.840.1.101.3.4.2.2";
+
+constexpr absl::string_view kMessageImprintHash = "fake message imprint";
+constexpr absl::string_view kValidTimestamp = "20240101000000Z";
+
+absl::Time ValidTimestampTime() {
+  static absl::Time time = []() {
+    absl::Time time;
+    std::string err;
+    ABSL_CHECK(absl::ParseTime(kGenTimeFormat, kValidTimestamp, &time, &err))
+        << err;
+    return time;
+  }();
+
+  return time;
+}
+
+bssl::UniquePtr<X509> ParseCert(absl::string_view pem) {
+  CBS cbs = FromStringView(pem);
+  bssl::UniquePtr<BIO> bio(BIO_new_mem_buf(CBS_data(&cbs), CBS_len(&cbs)));
+  ABSL_CHECK(bio != nullptr);
+  bssl::UniquePtr<X509> x509(
+      PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
+  ABSL_CHECK(x509 != nullptr);
+  return x509;
+}
+
+std::string RawDer(X509* x509) {
+  uint8_t* der_ptr = nullptr;
+  size_t der_len = i2d_X509(x509, &der_ptr);
+  bssl::UniquePtr<uint8_t> der(der_ptr);
+
+  return std::string(reinterpret_cast<char*>(der_ptr), der_len);
+}
+
+std::string RawDer(absl::string_view pem) {
+  return RawDer(ParseCert(pem).get());
+}
+
+absl::Time ParseGenTime(absl::string_view timestamp) {
+  absl::Time time;
+  std::string parse_error;
+  ABSL_CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error))
+      << "ParseTime(\"" << kGenTimeFormat << "\", \"" << timestamp
+      << "\"): " << parse_error;
+  return time;
+}
+
+absl::string_view ValidNonce() {
+  static absl::NoDestructor<std::string> nonce(EncodeDerInteger(0xf00dbabe));
+  return *nonce;
+}
+
+std::string SigningCertificateV2AttributeFromPem(
+    absl::Span<const absl::string_view> pems) {
+  std::vector<bssl::UniquePtr<X509>> certs;
+  for (absl::string_view pem : pems) {
+    certs.push_back(ParseCert(pem));
+  }
+  std::vector<X509*> ptr_vec;
+  absl::c_transform(certs, std::back_inserter(ptr_vec),
+                    [](const auto& cert) { return cert.get(); });
+  return CreateSigningCertificateV2Attribute(absl::MakeSpan(ptr_vec),
+                                             HashAlgorithm::kSha256);
+}
+
+class TimestampVerifierTest : public testing::Test {
+ protected:
+  TimestampVerifierTest() : no_trust_checks_(/*tsa_roots=*/nullptr) {}
+
+  void SetupMockDefaults(MockParsedTimestampToken& mock_token,
+                         const std::vector<std::string>& cert_chain,
+                         absl::string_view v2_val, absl::string_view tst_info) {
+    ON_CALL(mock_token, GetCertificateChain())
+        .WillByDefault(Return(cert_chain));
+    ON_CALL(mock_token, GetMessageImprintHashAlgorithm())
+        .WillByDefault(Return(HashAlgorithm::kSha384));
+    ON_CALL(mock_token, VerifySignature())
+        .WillByDefault(Return(absl::OkStatus()));
+    ON_CALL(mock_token, GetSigningCertificateV2Bytes())
+        .WillByDefault(Return(v2_val));
+    ON_CALL(mock_token, GetTstInfoBytes()).WillByDefault(Return(tst_info));
+  }
+
+  TimestampVerifier no_trust_checks_;
+};
+
+TEST_F(TimestampVerifierTest, AcceptsRsaTimestamp) {
+  std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash,
+                                       kSha384OidTxt, ValidNonce());
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaRsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token);
+  ABSL_ASSERT_OK(timestamp);
+  EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime());
+  EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash));
+  EXPECT_THAT(timestamp->message_imprint_hash_algorithm(),
+              Eq(HashAlgorithm::kSha384));
+  EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce()));
+  EXPECT_THAT(timestamp->tsa_certificate(), Eq(RawDer(kTsaRsaPem)));
+  EXPECT_THAT(timestamp->certificate_chain(), ElementsAre(RawDer(kTsaRsaPem)));
+}
+
+TEST_F(TimestampVerifierTest, AcceptsEcdsaTimestamp) {
+  std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash,
+                                       kSha384OidTxt, ValidNonce());
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token);
+  ABSL_ASSERT_OK(timestamp);
+  EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime());
+  EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash));
+  EXPECT_THAT(timestamp->message_imprint_hash_algorithm(),
+              Eq(HashAlgorithm::kSha384));
+  EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce()));
+  EXPECT_THAT(timestamp->tsa_certificate(), Eq(cert_der));
+  EXPECT_THAT(timestamp->certificate_chain(), ElementsAre(cert_der));
+}
+
+TEST_F(TimestampVerifierTest, AcceptsTimestampWithIntermediateCertificate) {
+  std::string tst_info = CreateTstInfo(kValidTimestamp, kMessageImprintHash,
+                                       kSha384OidTxt, ValidNonce());
+  std::string v2_attr =
+      SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaWithIntermediatePem);
+  std::string intermediate_der = RawDer(kIntermediatePem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der, intermediate_der}, v2_val, tst_info);
+
+  auto timestamp = no_trust_checks_.VerifyTimestampToken(mock_token);
+  ABSL_ASSERT_OK(timestamp);
+  EXPECT_EQ(timestamp->asserted_time(), ValidTimestampTime());
+  EXPECT_THAT(timestamp->message_imprint_hash(), Eq(kMessageImprintHash));
+  EXPECT_THAT(timestamp->message_imprint_hash_algorithm(),
+              Eq(HashAlgorithm::kSha384));
+  EXPECT_THAT(timestamp->nonce(), Eq(ValidNonce()));
+  EXPECT_THAT(timestamp->tsa_certificate(), Eq(cert_der));
+  EXPECT_THAT(timestamp->certificate_chain(),
+              ElementsAre(cert_der, intermediate_der));
+}
+
+TEST_F(TimestampVerifierTest, GetSigningCertificateV2BytesFails) {
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return(absl::InvalidArgumentError("Missing attribute")));
+
+  EXPECT_THAT(
+      no_trust_checks_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kInvalidArgument, "Missing attribute"));
+}
+
+TEST_F(TimestampVerifierTest, InvalidTstInfoFails) {
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {RawDer(kTsaRsaPem)}, v2_val,
+                    "THIS IS NOT BER OR DER OR CER OR AER OR ...");
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("could not read top-level sequence")));
+}
+
+TEST_F(TimestampVerifierTest, SignatureVerificationFails) {
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature())
+      .WillOnce(Return(absl::UnauthenticatedError("Signature failed")));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kUnauthenticated,
+                       HasSubstr("Signature failed")));
+}
+
+TEST_F(TimestampVerifierTest, EmptyChainFails) {
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInternal,
+                       HasSubstr("TSA leaf cert missing")));
+}
+
+TEST_F(TimestampVerifierTest, InvalidSigningCertificateAttribute) {
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return("THIS IS NOT A DER SEQUENCE"));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "could not parse DER SEQUENCE for SigningCertificateV2 "
+                       "attribute value"));
+}
+
+TEST_F(TimestampVerifierTest, UnrecognizedSiginingCertificateHashAlgorithm) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+
+  constexpr absl::string_view kFakeOid = "1.2.840.113549.1.800.888.8888";
+  CBS hash_cbs = FromStringView(
+      "thisisnotarealhashbutthetestwontcarebecauseitwillfailduetothebadoidanywa"
+      "ys");
+
+  CBB certs_entry_cbb;
+  ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+  CBB obj_cbb;
+  ABSL_CHECK(CBB_add_asn1(&certs_entry_cbb, &obj_cbb, CBS_ASN1_OBJECT));
+  ABSL_CHECK(
+      CBB_add_asn1_oid_from_text(&obj_cbb, kFakeOid.data(), kFakeOid.length()));
+  ABSL_CHECK(CBB_flush(&certs_entry_cbb));
+
+  ABSL_CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs),
+                                       CBS_len(&hash_cbs)));
+
+  auto value_bytes = FinishToString(&cbb);
+  ABSL_ASSERT_OK(value_bytes);
+
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return(*value_bytes));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "unrecognized OID in `certs.hashAlgorithm` field of "
+                       "SigningCertificateV2 attribute value; OID value: "
+                       "1.2.840.113549.1.800.888.8888"));
+}
+
+TEST_F(TimestampVerifierTest, NoSigningCertificateHash) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+
+  CBB certs_entry_cbb;
+  ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha384));
+  ABSL_CHECK(CBB_flush(&certs_entry_cbb));
+
+  auto value_bytes = FinishToString(&cbb);
+  ABSL_ASSERT_OK(value_bytes);
+
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return(*value_bytes));
+
+  EXPECT_THAT(
+      no_trust_checks_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               "could not parse `certs.hash` field (DER OCTETSTRING) of "
+               "SigningCertificateV2 attribute value"));
+}
+
+TEST_F(TimestampVerifierTest, UnsupportedSigningCertificateHashAlgorithm) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+
+  CBS hash_cbs = FromStringView("Not a real hash");
+
+  CBB certs_entry_cbb;
+  ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha1));
+  ABSL_CHECK(CBB_flush(&certs_entry_cbb));
+
+  ABSL_CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs),
+                                       CBS_len(&hash_cbs)));
+
+  auto value_bytes = FinishToString(&cbb);
+  ABSL_ASSERT_OK(value_bytes);
+
+  MockParsedTimestampToken mock_token;
+  EXPECT_CALL(mock_token, GetCertificateChain())
+      .WillOnce(Return(std::vector<std::string>{RawDer(kTsaRsaPem)}));
+  EXPECT_CALL(mock_token, GetMessageImprintHashAlgorithm())
+      .WillOnce(Return(HashAlgorithm::kSha384));
+  EXPECT_CALL(mock_token, VerifySignature()).WillOnce(Return(absl::OkStatus()));
+  EXPECT_CALL(mock_token, GetSigningCertificateV2Bytes())
+      .WillOnce(Return(*value_bytes));
+
+  EXPECT_THAT(no_trust_checks_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       "unsupported hash algorithm in `certs.hashAlgorithm` "
+                       "field of SigningCertificateV2 attribute value: hash "
+                       "algorithm NID is not on C2PA allowlist: 64"));
+}
+
+class TrustChecksTest : public ::testing::Test {
+ protected:
+  TrustChecksTest()
+      : tsa_roots_(LoadTestRoots().value()), verifier_(tsa_roots_.get()) {}
+
+  absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> LoadTestRoots() {
+    return LoadTrustStore(kCaPem);
+  }
+
+  void SetupMockDefaults(MockParsedTimestampToken& mock_token,
+                         const std::vector<std::string>& cert_chain,
+                         absl::string_view v2_val, absl::string_view tst_info) {
+    ON_CALL(mock_token, GetCertificateChain())
+        .WillByDefault(Return(cert_chain));
+    ON_CALL(mock_token, GetMessageImprintHashAlgorithm())
+        .WillByDefault(Return(HashAlgorithm::kSha384));
+    ON_CALL(mock_token, VerifySignature())
+        .WillByDefault(Return(absl::OkStatus()));
+    ON_CALL(mock_token, GetSigningCertificateV2Bytes())
+        .WillByDefault(Return(v2_val));
+    ON_CALL(mock_token, GetTstInfoBytes()).WillByDefault(Return(tst_info));
+  }
+
+  const std::unique_ptr<bssl::VerifyTrustStore> tsa_roots_;
+  TimestampVerifier verifier_;
+};
+
+TEST_F(TrustChecksTest, Trusted) {
+  constexpr absl::string_view kTime = "20250205000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  auto verified_timestamp = verifier_.VerifyTimestampToken(mock_token);
+  ABSL_ASSERT_OK(verified_timestamp);
+  EXPECT_THAT(verified_timestamp->asserted_time(), Eq(ParseGenTime(kTime)));
+}
+
+TEST_F(TrustChecksTest, TrustedWithIntermediate) {
+  constexpr absl::string_view kTime = "20250514000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr =
+      SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaWithIntermediatePem);
+  std::string intermediate_der = RawDer(kIntermediatePem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der, intermediate_der}, v2_val, tst_info);
+
+  auto verified_timestamp = verifier_.VerifyTimestampToken(mock_token);
+  ABSL_ASSERT_OK(verified_timestamp);
+  EXPECT_THAT(verified_timestamp->asserted_time(), Eq(ParseGenTime(kTime)));
+}
+
+TEST_F(TrustChecksTest, TooManyIntermediateCerts) {
+  constexpr absl::string_view kTime = "20250514000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr =
+      SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaWithIntermediatePem);
+  std::string intermediate_der = RawDer(kIntermediatePem);
+
+  MockParsedTimestampToken mock_token;
+  // Chain length 4 (1 leaf + 3 intermediates)
+  SetupMockDefaults(
+      mock_token,
+      {cert_der, intermediate_der, intermediate_der, intermediate_der}, v2_val,
+      tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kUnauthenticated,
+               HasSubstr("excessive timestamp certificate chain length")));
+}
+
+TEST_F(TrustChecksTest, TrustedMissingIntermediate) {
+  constexpr absl::string_view kTime = "20250514000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr =
+      SigningCertificateV2AttributeFromPem({kTsaWithIntermediatePem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaWithIntermediatePem);
+
+  MockParsedTimestampToken mock_token;
+  // Missing intermediate!
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kUnauthenticated,
+                       HasSubstr("No matching issuer found")));
+}
+
+TEST_F(TrustChecksTest, Untrusted) {
+  constexpr absl::string_view kTime = "20250205000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+
+  std::string dummy_cert = "DUMMY_CERT_DER";
+  // Compute SHA256 of dummy_cert using internal OpenSSL without hasher.h
+  uint8_t hash[32];
+  unsigned int hash_len;
+  ABSL_CHECK(EVP_Digest(dummy_cert.data(), dummy_cert.length(), hash, &hash_len,
+                        EVP_sha256(), nullptr));
+  std::string digest(reinterpret_cast<char*>(hash), hash_len);
+
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_entry_cbb;
+  ABSL_CHECK(CBB_add_asn1(&certs_cbb, &certs_entry_cbb, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(OBJ_nid2cbb(&certs_entry_cbb, NID_sha256));
+  ABSL_CHECK(CBB_flush(&certs_entry_cbb));
+  CBS hash_cbs = FromStringView(digest);
+  ABSL_CHECK(CBB_add_asn1_octet_string(&certs_entry_cbb, CBS_data(&hash_cbs),
+                                       CBS_len(&hash_cbs)));
+  auto v2_val = FinishToString(&cbb);
+  ABSL_ASSERT_OK(v2_val);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {dummy_cert}, *v2_val, tst_info);
+
+  EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kUnauthenticated,
+                       HasSubstr("timestamp certificate chain")));
+}
+
+TEST_F(TrustChecksTest, InvalidEku) {
+  constexpr absl::string_view kTime = "20250205000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kClaimSignerPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kClaimSignerPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  // The certificate has Email Protection EKU, which is not allowed for TSAs.
+  EXPECT_THAT(verifier_.VerifyTimestampToken(mock_token),
+              StatusIs(absl::StatusCode::kUnauthenticated,
+                       HasSubstr("prohibited key usage")));
+}
+
+TEST_F(TrustChecksTest, SigningCertificateV2AttributeLeafMismatch) {
+  constexpr absl::string_view kTime = "21250204161300Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaRsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(
+          absl::StatusCode::kUnauthenticated,
+          HasSubstr("TSA cert does not match SigningCertificateV2 attribute: "
+                    "certificate does not match EssCertIDV2 value")));
+}
+
+TEST_F(TrustChecksTest, EmptySigningCertificateAttribute) {
+  CBB cbb;
+  ABSL_CHECK(CBB_init(&cbb, 1000));
+  CBB value_seq_cbb;
+  ABSL_CHECK(CBB_add_asn1(&cbb, &value_seq_cbb, CBS_ASN1_SEQUENCE));
+  CBB certs_cbb;
+  ABSL_CHECK(CBB_add_asn1(&value_seq_cbb, &certs_cbb, CBS_ASN1_SEQUENCE));
+
+  auto value_bytes = FinishToString(&cbb);
+  ABSL_ASSERT_OK(value_bytes);
+
+  constexpr absl::string_view kTime = "20250205000000Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string cert_der = RawDer(kTsaRsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, *value_bytes, tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          "SigningCertificateV2 signed attribute is empty; at minimum, "
+          "TSAs must record their own leaf certificate in this attribute"));
+}
+
+TEST_F(TrustChecksTest, OutsideTsaCertValidityWindow) {
+  // This time is outside the validity window of the test TSA certificate,
+  // but inside the validity window of the test CA certificate.
+  constexpr absl::string_view kTime = "20250204161300Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kOutOfRange, HasSubstr("before notBefore")));
+}
+
+TEST_F(TrustChecksTest, OutsideCaCertValidityWindow) {
+  // This time is inside the validity window of the test TSA certificate,
+  // but outside the validity window of the test CA certificate.
+  constexpr absl::string_view kTime = "21250204161300Z";
+  std::string tst_info =
+      CreateTstInfo(kTime, kMessageImprintHash, kSha384OidTxt);
+  std::string v2_attr = SigningCertificateV2AttributeFromPem({kTsaPem});
+  std::string v2_val = GetSigningCertificateV2Value(v2_attr);
+  std::string cert_der = RawDer(kTsaPem);
+
+  MockParsedTimestampToken mock_token;
+  SetupMockDefaults(mock_token, {cert_der}, v2_val, tst_info);
+
+  EXPECT_THAT(
+      verifier_.VerifyTimestampToken(mock_token),
+      StatusIs(absl::StatusCode::kOutOfRange, HasSubstr("after notAfter")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/tsp/verified_timestamp.h b/tsp/verified_timestamp.h
new file mode 100644
index 0000000..c9d7004
--- /dev/null
+++ b/tsp/verified_timestamp.h
@@ -0,0 +1,99 @@
+// 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_TSP_VERIFIED_TIMESTAMP_H_
+#define THIRD_PARTY_CREDENTIO_TSP_VERIFIED_TIMESTAMP_H_
+
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+
+namespace credentio {
+
+// Represents a TimeStampToken (RFC 3161 section 2.4.2) that has passed
+// signature checks. The trustworthiness of signatures is not checked, callers
+// should verify this before use.
+class VerifiedTimestamp final {
+ public:
+  static absl::StatusOr<VerifiedTimestamp> Create(
+      absl::Time asserted_time, std::vector<std::string> certificate_chain,
+      std::string message_imprint_hash,
+      HashAlgorithm message_imprint_hash_algorithm, std::string nonce) {
+    if (certificate_chain.empty()) {
+      return absl::InvalidArgumentError("certificate_chain must not be empty");
+    }
+    return VerifiedTimestamp(asserted_time, std::move(certificate_chain),
+                             std::move(message_imprint_hash),
+                             message_imprint_hash_algorithm, std::move(nonce));
+  }
+
+  // The time at which the timestamp was generated (genTime in RFC 3161).
+  absl::Time asserted_time() const { return asserted_time_; }
+
+  // The signing certificate of the Time Stamping Authority (TSA), DER-encoded.
+  absl::string_view tsa_certificate() const {
+    return certificate_chain_.front();
+  }
+
+  // The full certificate chain of the TSA, starting with the signing
+  // certificate, DER-encoded.
+  absl::Span<const std::string> certificate_chain() const {
+    return certificate_chain_;
+  }
+
+  // The hash value of the data that was timestamped (hashedMessage in RFC
+  // 3161).
+  absl::string_view message_imprint_hash() const {
+    return message_imprint_hash_;
+  }
+
+  // The hash algorithm used to calculate the message imprint.
+  HashAlgorithm message_imprint_hash_algorithm() const {
+    return message_imprint_hash_algorithm_;
+  }
+
+  // The ASN.1 / DER INTEGER element, with tag / length prefix. May be empty if
+  // the field was absent in the source TimeStampToken.
+  absl::string_view nonce() const { return nonce_; }
+
+ private:
+  VerifiedTimestamp(absl::Time asserted_time,
+                    std::vector<std::string>&& certificate_chain,
+                    std::string&& message_imprint_hash,
+                    HashAlgorithm message_imprint_hash_algorithm,
+                    std::string nonce)
+      : asserted_time_(asserted_time),
+        certificate_chain_(std::move(certificate_chain)),
+        message_imprint_hash_(std::move(message_imprint_hash)),
+        message_imprint_hash_algorithm_(message_imprint_hash_algorithm),
+        nonce_(std::move(nonce)) {}
+
+  absl::Time asserted_time_;
+  std::vector<std::string> certificate_chain_;
+  std::string message_imprint_hash_;
+  HashAlgorithm message_imprint_hash_algorithm_;
+  std::string nonce_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TSP_VERIFIED_TIMESTAMP_H_
diff --git a/utils/BUILD b/utils/BUILD
new file mode 100644
index 0000000..0330b49
--- /dev/null
+++ b/utils/BUILD
@@ -0,0 +1,290 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "riegeli",
+    srcs = ["riegeli.cc"],
+    hdrs = ["riegeli.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/base:arithmetic",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_reading",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "riegeli_test",
+    srcs = ["riegeli_test.cc"],
+    deps = [
+        ":riegeli",
+        "//testing:test_string_utils",
+        "@abseil-cpp//absl/functional:function_ref",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "distinguished_name",
+    srcs = ["distinguished_name.cc"],
+    hdrs = ["distinguished_name.h"],
+    deps = [
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "distinguished_name_test",
+    srcs = ["distinguished_name_test.cc"],
+    deps = [
+        ":distinguished_name",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "byte_readers",
+    hdrs = ["byte_readers.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/endian:endian_reading",
+    ],
+)
+
+cc_test(
+    name = "byte_readers_test",
+    srcs = ["byte_readers_test.cc"],
+    deps = [
+        ":byte_readers",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "byte_writers",
+    srcs = ["byte_writers.cc"],
+    hdrs = ["byte_writers.h"],
+    deps = [
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "byte_writers_test",
+    srcs = ["byte_writers_test.cc"],
+    deps = [
+        ":byte_writers",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "uri",
+    srcs = ["uri.cc"],
+    hdrs = ["uri.h"],
+    deps = [
+        "//constants:labels",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "uri_test",
+    srcs = ["uri_test.cc"],
+    deps = [
+        ":uri",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "byte_instruction",
+    srcs = ["byte_instruction.cc"],
+    hdrs = ["byte_instruction.h"],
+    deps = [
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/base:chain",
+        "@riegeli//riegeli/bytes:chain_reader",
+        "@riegeli//riegeli/bytes:copy_all",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "byte_instruction_test",
+    srcs = ["byte_instruction_test.cc"],
+    deps = [
+        ":byte_instruction",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/types:span",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "media_type",
+    srcs = ["media_type.cc"],
+    hdrs = ["media_type.h"],
+    deps = [
+        "@abseil-cpp//absl/base:no_destructor",
+        "@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",
+    ],
+)
+
+cc_test(
+    name = "media_type_test",
+    srcs = ["media_type_test.cc"],
+    deps = [
+        ":media_type",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "status_tracker",
+    hdrs = ["status_tracker.h"],
+    deps = [
+        "//constants:status_codes",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//validator:tracker",
+    ],
+)
+
+cc_library(
+    name = "two_stage_status_tracker",
+    srcs = ["two_stage_status_tracker.cc"],
+    hdrs = ["two_stage_status_tracker.h"],
+    deps = [
+        ":status_tracker",
+        "//constants:status_codes",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//validator:tracker",
+    ],
+)
+
+cc_library(
+    name = "dual_status_tracker",
+    srcs = ["dual_status_tracker.cc"],
+    hdrs = ["dual_status_tracker.h"],
+    deps = [
+        ":status_tracker",
+        "//constants:status_codes",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//validator:result",
+        "//validator:tracker",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/memory",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+    ],
+)
+
+cc_test(
+    name = "two_stage_status_tracker_test",
+    srcs = ["two_stage_status_tracker_test.cc"],
+    deps = [
+        ":status_tracker",
+        ":two_stage_status_tracker",
+        "//constants:status_codes",
+        "//proto:assertion_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:proto_test_utils",
+        "//testing:test_validation_tracker",
+        "//validator:tracker",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_test(
+    name = "dual_status_tracker_test",
+    srcs = ["dual_status_tracker_test.cc"],
+    deps = [
+        ":dual_status_tracker",
+        "//constants:status_codes",
+        "//proto:assertion_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/utils/byte_instruction.cc b/utils/byte_instruction.cc
new file mode 100644
index 0000000..c04b707
--- /dev/null
+++ b/utils/byte_instruction.cc
@@ -0,0 +1,286 @@
+// 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/byte_instruction.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "riegeli/base/chain.h"
+#include "riegeli/bytes/chain_reader.h"
+#include "riegeli/bytes/copy_all.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+namespace {
+
+constexpr uint64_t kBufferChunkSize = 1024 * 1024;  // 1 MiB
+
+absl::Status ReadDataToBuffer(riegeli::Writer& writer, int64_t length,
+                              riegeli::Chain& buffer) {
+  // Flush the writer to make sure the data is available for reading.
+  if (!writer.Flush()) {
+    return writer.status();
+  }
+
+  // Create a reader at the current position of the writer.
+  riegeli::Reader* reader = writer.ReadMode(writer.pos());
+  if (reader == nullptr) {
+    return writer.status();
+  }
+
+  // Read data from the reader to the buffer.
+  if (!reader->ReadAndAppend(length, buffer)) {
+    return reader->StatusOrAnnotate(
+        absl::DataLossError("Failed to read data from buffer"));
+  }
+
+  // Close the reader.
+  if (!reader->Close()) {
+    return reader->StatusOrAnnotate(
+        absl::DataLossError("Failed to close reader"));
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status WriteDataFromBuffer(riegeli::Chain& buffer, int64_t length,
+                                 riegeli::Writer& writer) {
+  // Create a reader for the buffer.
+  riegeli::ChainReader cr(&buffer);
+
+  // Read data from the buffer.
+  absl::string_view data;
+  if (!cr.Read(length, data)) {
+    return cr.StatusOrAnnotate(
+        absl::DataLossError("Failed to read data from buffer"));
+  }
+
+  // Write the data to the writer.
+  if (!writer.Write(data)) {
+    return writer.status();
+  }
+
+  // Close the reader.
+  if (!cr.Close()) {
+    return cr.StatusOrAnnotate(absl::DataLossError("Failed to close reader"));
+  }
+
+  // Remove data from the buffer, done after writing as the absl::string_view
+  // data is a ref into the buffer.
+  buffer.RemovePrefix(length);
+
+  return absl::OkStatus();
+}
+
+absl::Status MoveDataThroughBuffer(riegeli::Chain& buffer, int64_t length,
+                                   riegeli::Writer& writer) {
+  ABSL_RETURN_IF_ERROR(ReadDataToBuffer(writer, length, buffer));
+  return WriteDataFromBuffer(buffer, length, writer);
+}
+
+absl::Status MoveDataThroughBuffer(riegeli::Chain& buffer,
+                                   absl::string_view data,
+                                   riegeli::Writer& writer) {
+  buffer.Append(data);
+  return WriteDataFromBuffer(buffer, data.size(), writer);
+}
+
+absl::Status PushData(riegeli::Writer& writer, riegeli::Chain& buffer,
+                      int64_t end_position) {
+  if (buffer.empty()) {
+    // We didn't insert anything yet, so just seek
+    if (!writer.Seek(end_position)) {
+      return writer.StatusOrAnnotate(
+          absl::DataLossError("Failed to seek writer"));
+    }
+    return absl::OkStatus();
+  }
+
+  uint64_t remaining_byte_count = end_position - writer.pos();
+
+  while (remaining_byte_count > 0) {
+    uint64_t count_to_move = std::min(kBufferChunkSize, remaining_byte_count);
+
+    ABSL_RETURN_IF_ERROR(MoveDataThroughBuffer(buffer, count_to_move, writer));
+
+    remaining_byte_count -= count_to_move;
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::Status ApplyByteInstructions(
+    riegeli::Reader* absl_nonnull source,
+    absl::Span<const ByteInstruction> instructions,
+    riegeli::Writer* absl_nonnull destination) {
+  if (!source->Seek(0) || !destination->Seek(0)) {
+    return absl::DataLossError("failed to seek to start of file");
+  }
+
+  if (instructions.empty()) {
+    // No instructions, just copy the source to the destination.
+    return riegeli::CopyAll(*source, *destination);
+  }
+
+  if (!source->SupportsSize() || !source->Size().has_value()) {
+    return absl::InvalidArgumentError("Source does not have a size.");
+  }
+  uint64_t file_size = source->Size().value_or(0);
+  uint64_t instruction_index = 0;
+
+  while (source->pos() < file_size && instruction_index < instructions.size()) {
+    const ByteInstruction& ins = instructions[instruction_index];
+
+    if (ins.offset < source->pos()) {
+      return absl::InvalidArgumentError(
+          "Byte instruction offsets must be in ascending order");
+    }
+    if (ins.offset >= file_size) {
+      // Next instruction is at or past the end of the file.
+      break;
+    }
+
+    // Copy enough data from the source to reach the instruction offset.
+    if (source->pos() < ins.offset) {
+      if (!source->Copy(ins.offset - source->pos(), *destination)) {
+        ABSL_RETURN_IF_ERROR(source->status());
+        ABSL_RETURN_IF_ERROR(destination->status());
+        return absl::DataLossError("Unexpected EOF while copying source data");
+      }
+    }
+
+    // Write the data
+    if (!destination->Write(ins.bytes)) {
+      return destination->status();
+    }
+
+    if (ins.operation == ByteInstruction::Operation::kReplace) {
+      uint64_t bytes_to_skip =
+          std::min<uint64_t>(ins.bytes.size(), file_size - source->pos());
+      if (!source->Skip(bytes_to_skip)) {
+        ABSL_RETURN_IF_ERROR(source->status());
+        return absl::DataLossError("Unexpected EOF while skipping source data");
+      }
+    }
+
+    ++instruction_index;
+  }
+
+  // Copy any remaining data from the source to the destination.
+  if (source->pos() < file_size) {
+    ABSL_RETURN_IF_ERROR(riegeli::CopyAll(*source, *destination));
+  }
+
+  // Write any remaining instructions.
+  while (instruction_index < instructions.size()) {
+    const ByteInstruction& ins = instructions[instruction_index];
+    if (!destination->Write(ins.bytes)) {
+      return destination->status();
+    }
+    ++instruction_index;
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status ApplyByteInstructions(
+    absl::Span<const credentio::ByteInstruction> instructions,
+    riegeli::Writer* absl_nonnull writer) {
+  if (!writer->Seek(0)) {
+    return absl::DataLossError("failed to seek to start of file");
+  }
+
+  if (instructions.empty()) {
+    return absl::OkStatus();
+  }
+
+  if (!writer->Size().has_value()) {
+    return absl::InvalidArgumentError("Writer does not have a size.");
+  }
+
+  uint64_t file_size = writer->Size().value();
+  riegeli::Chain buffer;
+
+  uint64_t instruction_index = 0;
+
+  while (writer->pos() < file_size && instruction_index < instructions.size()) {
+    const ByteInstruction& ins = instructions[instruction_index];
+
+    if (ins.offset < writer->pos()) {
+      return absl::InvalidArgumentError(
+          "Byte instruction offsets must be in ascending order");
+    }
+    if (ins.offset >= file_size) {
+      // Next instruction is at or past the end of the file.
+      break;
+    }
+
+    // Process enough data to reach the instruction offset.
+    ABSL_RETURN_IF_ERROR(PushData(*writer, buffer, ins.offset));
+
+    if (ins.operation == credentio::ByteInstruction::Operation::kInsert) {
+      // Insert operations do not impact the current offset, just add to the
+      // buffer.
+      buffer.Append(ins.bytes);
+    } else if (ins.operation ==
+               credentio::ByteInstruction::Operation::kReplace) {
+      if (buffer.empty()) {
+        // If the buffer is empty, we can write the data directly to the writer.
+        if (!writer->Write(ins.bytes)) {
+          return writer->status();
+        }
+      } else {
+        // If the buffer is not empty, we need to move the data through the
+        // buffer.
+        ABSL_RETURN_IF_ERROR(MoveDataThroughBuffer(buffer, ins.bytes, *writer));
+      }
+    }
+
+    ++instruction_index;
+  }
+
+  // Process all remaining data up to the end of the file.
+  if (writer->pos() < file_size) {
+    ABSL_RETURN_IF_ERROR(PushData(*writer, buffer, file_size));
+  }
+  if (!buffer.empty()) {
+    // Write any remaining data in the buffer.
+    if (!writer->Write(buffer)) {
+      return writer->status();
+    }
+  }
+
+  // Write any remaining instructions.
+  while (instruction_index < instructions.size()) {
+    if (!writer->Write(instructions[instruction_index].bytes)) {
+      return writer->status();
+    }
+    ++instruction_index;
+  }
+
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/utils/byte_instruction.h b/utils/byte_instruction.h
new file mode 100644
index 0000000..d72f3e5
--- /dev/null
+++ b/utils/byte_instruction.h
@@ -0,0 +1,78 @@
+// 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_BYTE_INSTRUCTION_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_BYTE_INSTRUCTION_H_
+
+#include <cstdint>
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/types/span.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// A ByteInstruction is a single instruction for modifying a target asset.
+// The instructions are applied in order and are based on the original asset.
+struct ByteInstruction {
+  enum class Operation {
+    kInsert,   // Add the bytes in this instruction to the destination.
+    kReplace,  // Overwrite the bytes in the destination with the bytes in this
+               // instruction.
+  };
+
+  Operation operation;
+  // The offset of the destination in the target asset, based on the start of
+  // the asset before any adjustments are made.
+  uint64_t offset;
+  std::string bytes;
+
+  bool operator==(const ByteInstruction& other) const {
+    return operation == other.operation && offset == other.offset &&
+           bytes == other.bytes;
+  }
+};
+
+// Applies a series of byte instructions to a source reader, writing the
+// modified content to a destination writer.
+//
+// Bytes are copied from `source` to `destination` based on instructions:
+// - If `instruction.operation` is `kInsert`, `instruction.bytes` are inserted
+//   at `instruction.offset`.
+// - If `instruction.operation` is `kReplace`, `instruction.bytes` replace
+//   `instruction.bytes.size()` bytes in `source` starting at
+//   `instruction.offset`.
+//
+// After all instructions are applied, any remaining bytes in `source` are
+// copied to `destination`.
+//
+// Returns an error if `source` or `destination` fail or if the wrong number of
+// bytes are copied or if the instructions are not in ascending order.
+absl::Status ApplyByteInstructions(
+    riegeli::Reader* absl_nonnull source,
+    absl::Span<const ByteInstruction> instructions,
+    riegeli::Writer* absl_nonnull destination);
+// Does the same as above, but applies the instructions in place to the given
+// writer.
+absl::Status ApplyByteInstructions(
+    absl::Span<const credentio::ByteInstruction> instructions,
+    riegeli::Writer* absl_nonnull writer);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_BYTE_INSTRUCTION_H_
diff --git a/utils/byte_instruction_test.cc b/utils/byte_instruction_test.cc
new file mode 100644
index 0000000..5d0c0b0
--- /dev/null
+++ b/utils/byte_instruction_test.cc
@@ -0,0 +1,400 @@
+// 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/byte_instruction.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+struct SuccessfulApplicationTestCase {
+  std::string name;
+  std::string input;
+  std::vector<ByteInstruction> instructions;
+  std::string expected;
+};
+
+using SuccessfulApplicationTest =
+    testing::TestWithParam<SuccessfulApplicationTestCase>;
+
+INSTANTIATE_TEST_SUITE_P(
+    ParameterizedTests, SuccessfulApplicationTest,
+    testing::ValuesIn<SuccessfulApplicationTestCase>({
+        {
+            .name = "NoInstructions",
+            .input = "hello world",
+            .instructions = {},
+            .expected = "hello world",
+        },
+        {
+            .name = "EmptyInsert",
+            .input = "",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kInsert,
+                     .offset = 0,
+                     .bytes = "foo"},
+                },
+            .expected = "foo",
+        },
+        {
+            .name = "EmptyReplace",
+            .input = "",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kReplace,
+                     .offset = 0,
+                     .bytes = "foo"},
+                },
+            .expected = "foo",
+        },
+        {
+            .name = "EmptyInstruction",
+            .input = "bar",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kInsert,
+                     .offset = 0,
+                     .bytes = ""},
+                },
+            .expected = "bar",
+        },
+        {
+            .name = "InsertAtStart",
+            .input = "bar",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kInsert,
+                     .offset = 0,
+                     .bytes = "foo"},
+                },
+            .expected = "foobar",
+        },
+        {
+            .name = "InsertAtEnd",
+            .input = "bar",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kInsert,
+                     .offset = 3,
+                     .bytes = "foo"},
+                },
+            .expected = "barfoo",
+        },
+        {
+            .name = "ReplaceAtStart",
+            .input = "bar",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kReplace,
+                     .offset = 0,
+                     .bytes = "foo"},
+                },
+            .expected = "foo",
+        },
+        {
+            .name = "ReplaceAtEnd",
+            .input = "bar",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kReplace,
+                     .offset = 3,
+                     .bytes = "foo"},
+                },
+            .expected = "barfoo",
+        },
+        {
+            .name = "InsertAtMiddle",
+            .input = "bar",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kInsert,
+                     .offset = 1,
+                     .bytes = "foo"},
+                },
+            .expected = "bfooar",
+        },
+        {
+            .name = "ReplaceAtMiddle",
+            .input = "bar",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kReplace,
+                     .offset = 1,
+                     .bytes = "foo"},
+                },
+            .expected = "bfoo",
+        },
+        {
+            .name = "ReplaceInsertReplace",
+            .input = "0_________0_________0_________0_________",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kReplace,
+                     .offset = 10,
+                     .bytes = "replace"},
+                    {.operation = ByteInstruction::Operation::kInsert,
+                     .offset = 17,
+                     .bytes = "insert"},
+                    {.operation = ByteInstruction::Operation::kReplace,
+                     .offset = 30,
+                     .bytes = "second"},
+                },
+            .expected = "0_________replaceinsert___0_________second____",
+        },
+        {
+            .name = "InsertPastEndOfInput",
+            .input = "hello world",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kInsert,
+                     .offset = 30,
+                     .bytes = " goodnight"},
+                },
+            .expected = "hello world goodnight",
+        },
+        {
+            .name = "ReplacePastEndOfInput",
+            .input = "hello world",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kReplace,
+                     .offset = 30,
+                     .bytes = " goodnight"},
+                },
+            .expected = "hello world goodnight",
+        },
+        {
+            .name = "ReplacePastEndOfInputWithTrailingInstructions",
+            .input = "hello world",
+            .instructions =
+                {
+                    {.operation = ByteInstruction::Operation::kReplace,
+                     .offset = 6,
+                     .bytes = "world goodnight"},
+                    {.operation = ByteInstruction::Operation::kInsert,
+                     .offset = 30,
+                     .bytes = " and goodbye"},
+                },
+            .expected = "hello world goodnight and goodbye",
+        },
+    }),
+    [](const testing::TestParamInfo<SuccessfulApplicationTest::ParamType>&
+           info) { return info.param.name; });
+
+TEST_P(SuccessfulApplicationTest, AppliesByteInstructions) {
+  riegeli::StringReader<std::string> reader(GetParam().input);
+  ASSERT_THAT(reader.status(), IsOk());
+  riegeli::StringWriter<std::string> writer;
+  ASSERT_THAT(writer.status(), IsOk());
+
+  EXPECT_THAT(ApplyByteInstructions(&reader, GetParam().instructions, &writer),
+              IsOk());
+
+  ASSERT_TRUE(reader.VerifyEndAndClose());
+  ASSERT_TRUE(writer.Close());
+
+  EXPECT_EQ(writer.dest(), GetParam().expected);
+}
+
+TEST_P(SuccessfulApplicationTest, AppliesByteInstructionsInPlace) {
+  riegeli::StringWriter<std::string> writer(
+      GetParam().input, riegeli::StringWriterBase::Options().set_append(true));
+  ASSERT_THAT(writer.status(), IsOk());
+
+  EXPECT_THAT(ApplyByteInstructions(GetParam().instructions, &writer), IsOk());
+
+  ASSERT_TRUE(writer.Close());
+
+  EXPECT_EQ(writer.dest(), GetParam().expected);
+}
+
+TEST(SuccessfulApplicationTest, ReaderNotAtStart) {
+  riegeli::StringReader<> reader("hello world");
+  ASSERT_THAT(reader.status(), IsOk());
+  ASSERT_TRUE(reader.Seek(8));
+
+  riegeli::StringWriter<std::string> writer;
+  ASSERT_THAT(writer.status(), IsOk());
+
+  EXPECT_THAT(
+      ApplyByteInstructions(&reader,
+                            {{.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 6,
+                              .bytes = "W"}},
+                            &writer),
+      IsOk());
+
+  ASSERT_TRUE(reader.VerifyEndAndClose());
+  ASSERT_TRUE(writer.Close());
+
+  EXPECT_EQ(writer.dest(), "hello World");
+}
+
+TEST(SuccessfulApplicationTest, WriterNotAtStart) {
+  riegeli::StringWriter<std::string> writer(
+      "hello world", riegeli::StringWriterBase::Options().set_append(true));
+  ASSERT_THAT(writer.status(), IsOk());
+  ASSERT_TRUE(writer.Seek(8));
+
+  EXPECT_THAT(
+      ApplyByteInstructions({{.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 6,
+                              .bytes = "W"}},
+                            &writer),
+      IsOk());
+
+  ASSERT_TRUE(writer.Close());
+
+  EXPECT_EQ(writer.dest(), "hello World");
+}
+
+TEST(FailedApplicationTest, ReaderClosed) {
+  riegeli::StringReader<> reader("hello world");
+  ASSERT_THAT(reader.status(), IsOk());
+  reader.Close();
+
+  riegeli::StringWriter<std::string> writer;
+  ASSERT_THAT(writer.status(), IsOk());
+
+  EXPECT_THAT(ApplyByteInstructions(&reader, {}, &writer),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       HasSubstr("Object closed")));
+}
+
+TEST(FailedApplicationTest, WriterClosed) {
+  riegeli::StringReader<> reader("hello world");
+  ASSERT_THAT(reader.status(), IsOk());
+
+  riegeli::StringWriter<std::string> writer;
+  ASSERT_THAT(writer.status(), IsOk());
+  writer.Close();
+
+  EXPECT_THAT(ApplyByteInstructions(&reader, {}, &writer),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       HasSubstr("Object closed")));
+}
+
+TEST(FailedApplicationTest, InstructionOutOfOrder) {
+  riegeli::StringReader<> reader("hello world");
+  ASSERT_THAT(reader.status(), IsOk());
+
+  riegeli::StringWriter<std::string> writer;
+  ASSERT_THAT(writer.status(), IsOk());
+
+  EXPECT_THAT(
+      ApplyByteInstructions(&reader,
+                            {{.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 5,
+                              .bytes = "da"},
+                             {.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 2,
+                              .bytes = "da"}},
+                            &writer),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("must be in ascending order")));
+}
+
+class PrematureEofReader : public riegeli::StringReader<> {
+ public:
+  PrematureEofReader(absl::string_view data, uint64_t fake_size)
+      : riegeli::StringReader<>(data), fake_size_(fake_size) {}
+
+  std::optional<riegeli::Position> SizeImpl() override { return fake_size_; }
+
+ private:
+  uint64_t fake_size_;
+};
+
+TEST(FailedApplicationTest, UnexpectedEofDuringCopy) {
+  PrematureEofReader reader("hello", 100);
+  ASSERT_THAT(reader.status(), IsOk());
+
+  riegeli::StringWriter<std::string> writer;
+  ASSERT_THAT(writer.status(), IsOk());
+
+  EXPECT_THAT(
+      ApplyByteInstructions(&reader,
+                            {{.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 10,
+                              .bytes = "x"}},
+                            &writer),
+      StatusIs(absl::StatusCode::kDataLoss,
+               HasSubstr("Unexpected EOF while copying source data")));
+}
+
+TEST(FailedApplicationTest, UnexpectedEofDuringSkip) {
+  PrematureEofReader reader("hello", 100);
+  ASSERT_THAT(reader.status(), IsOk());
+
+  riegeli::StringWriter<std::string> writer;
+  ASSERT_THAT(writer.status(), IsOk());
+
+  EXPECT_THAT(
+      ApplyByteInstructions(&reader,
+                            {{.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 0,
+                              .bytes = "xxxxxxxxxx"}},
+                            &writer),
+      StatusIs(absl::StatusCode::kDataLoss,
+               HasSubstr("Unexpected EOF while skipping source data")));
+}
+
+TEST(FailedApplicationInPlaceTest, WriterClosed) {
+  riegeli::StringWriter<std::string> writer(
+      "hello world", riegeli::StringWriterBase::Options().set_append(true));
+  ASSERT_THAT(writer.status(), IsOk());
+  writer.Close();
+
+  EXPECT_THAT(ApplyByteInstructions({}, &writer),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("failed to seek to start of file")));
+}
+
+TEST(FailedApplicationInPlaceTest, InstructionOutOfOrder) {
+  riegeli::StringWriter<std::string> writer(
+      "hello world", riegeli::StringWriterBase::Options().set_append(true));
+  ASSERT_THAT(writer.status(), IsOk());
+
+  EXPECT_THAT(
+      ApplyByteInstructions({{.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 5,
+                              .bytes = "da"},
+                             {.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 2,
+                              .bytes = "da"}},
+                            &writer),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("must be in ascending order")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/byte_readers.h b/utils/byte_readers.h
new file mode 100644
index 0000000..970a2ec
--- /dev/null
+++ b/utils/byte_readers.h
@@ -0,0 +1,74 @@
+// 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_BYTE_READERS_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_BYTE_READERS_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 "riegeli/endian/endian_reading.h"
+
+namespace credentio {
+
+// Reads a big-endian unsigned integer of type `N` from the supplied segment.
+// Returns an error if the segment is not large enough to contain the integer.
+template <typename N>
+absl::StatusOr<N> ReadUint(absl::string_view segment) {
+  if (segment.size() < sizeof(N)) {
+    return absl::InvalidArgumentError(absl::StrFormat(
+        "Segment of length %d is not large enough to contain a %d byte "
+        "unsigned integer",
+        segment.size(), sizeof(N)));
+  }
+  return riegeli::ReadBigEndian<N>(segment.data());
+}
+
+// Reads a big-endian unsigned integer of type `N` from the supplied segment and
+// removes the bytes from the segment. Returns an error if the segment is not
+// large enough to contain the integer and does not remove any bytes from the
+// segment.
+template <typename N>
+absl::StatusOr<N> ConsumeUint(absl::string_view* segment) {
+  if (segment == nullptr) {
+    return absl::InvalidArgumentError("Segment is null");
+  }
+  ABSL_ASSIGN_OR_RETURN(auto result, ReadUint<N>(*segment));
+  segment->remove_prefix(sizeof(N));
+  return result;
+}
+
+// Skips `sizeof(N)` bytes from the supplied segment. Returns an error if the
+// segment is not large enough to contain `sizeof(N)` bytes and does not remove
+// any bytes from the segment.
+template <typename N>
+absl::Status SkipBytes(absl::string_view* segment) {
+  if (segment == nullptr) {
+    return absl::InvalidArgumentError("Segment is null");
+  }
+  if (segment->size() < sizeof(N)) {
+    return absl::InvalidArgumentError(absl::StrFormat(
+        "Segment of length %d is not large enough to skip %d bytes",
+        segment->size(), sizeof(N)));
+  }
+  segment->remove_prefix(sizeof(N));
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_BYTE_READERS_H_
diff --git a/utils/byte_readers_test.cc b/utils/byte_readers_test.cc
new file mode 100644
index 0000000..5a3773c
--- /dev/null
+++ b/utils/byte_readers_test.cc
@@ -0,0 +1,96 @@
+// 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/byte_readers.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+
+TEST(ByteReadersTest, ReadTooSmall) {
+  EXPECT_THAT(ReadUint<uint16_t>("\x12"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ByteReadersTest, ReadUint) {
+  EXPECT_THAT(ReadUint<uint16_t>("\x12\x34"), IsOkAndHolds(0x1234));
+}
+
+TEST(ByteReadersTest, ReadUintWithRemainder) {
+  EXPECT_THAT(ReadUint<uint16_t>("\x12\x34\x56\x78"), IsOkAndHolds(0x1234));
+}
+
+TEST(ByteReadersTest, ConsumeNull) {
+  EXPECT_THAT(ConsumeUint<uint16_t>(nullptr),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ByteReadersTest, ConsumeTooSmall) {
+  absl::string_view input = "\x12";
+  EXPECT_THAT(ConsumeUint<uint16_t>(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_EQ(input, "\x12");
+}
+
+TEST(ByteReadersTest, ConsumeUint) {
+  absl::string_view input = "\x12\x34";
+  EXPECT_THAT(ConsumeUint<uint16_t>(&input), IsOkAndHolds(0x1234));
+  EXPECT_EQ(input, "");
+}
+
+TEST(ByteReadersTest, ConsumeUintWithRemainder) {
+  absl::string_view input = "\x12\x34\x56\x78";
+  EXPECT_THAT(ConsumeUint<uint16_t>(&input), IsOkAndHolds(0x1234));
+  EXPECT_EQ(input, "\x56\x78");
+}
+
+TEST(ByteReadersTest, SkipBytesNull) {
+  EXPECT_THAT(SkipBytes<uint16_t>(nullptr),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ByteReadersTest, SkipBytes) {
+  absl::string_view input = "\x12\x34";
+  EXPECT_THAT(SkipBytes<uint8_t>(&input), IsOk());
+  EXPECT_EQ(input, "\x34");
+}
+
+TEST(ByteReadersTest, SkipBytesAll) {
+  absl::string_view input = "\x12\x34";
+  EXPECT_THAT(SkipBytes<uint16_t>(&input), IsOk());
+  EXPECT_EQ(input, "");
+}
+
+TEST(ByteReadersTest, SkipBytesNotEnough) {
+  absl::string_view input = "\x12\x34";
+  EXPECT_THAT(SkipBytes<uint32_t>(&input),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_EQ(input, "\x12\x34");
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/byte_writers.cc b/utils/byte_writers.cc
new file mode 100644
index 0000000..a194884
--- /dev/null
+++ b/utils/byte_writers.cc
@@ -0,0 +1,49 @@
+// 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/byte_writers.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <vector>
+
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+
+namespace {
+// Best to specify template parameter explicitly when calling this.
+template <class T>
+void WriteNetworkOrder(T value, std::vector<uint8_t>* bytes) {
+  size_t n = bytes->size();
+  bytes->resize(n + sizeof(T));
+  riegeli::WriteBigEndian<T>(value, reinterpret_cast<char*>(bytes->data() + n));
+}
+
+}  // namespace
+
+void WriteUint16NetworkOrder(uint16_t value, std::vector<uint8_t>* bytes) {
+  WriteNetworkOrder<uint16_t>(value, bytes);
+}
+
+void WriteUint32NetworkOrder(uint32_t value, std::vector<uint8_t>* bytes) {
+  WriteNetworkOrder<uint32_t>(value, bytes);
+}
+
+void WriteUint64NetworkOrder(uint64_t value, std::vector<uint8_t>* bytes) {
+  WriteNetworkOrder<uint64_t>(value, bytes);
+}
+
+}  // namespace credentio
diff --git a/utils/byte_writers.h b/utils/byte_writers.h
new file mode 100644
index 0000000..419f0dd
--- /dev/null
+++ b/utils/byte_writers.h
@@ -0,0 +1,38 @@
+// 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_BYTE_WRITERS_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_BYTE_WRITERS_H_
+
+#include <cstdint>
+#include <vector>
+
+namespace credentio {
+
+// Writes the given unsigned 16-bit integer to the given vector in big endian
+// format.
+void WriteUint16NetworkOrder(uint16_t value, std::vector<uint8_t>* bytes);
+
+// Writes the given unsigned 32-bit integer to the given vector in big endian
+// format.
+void WriteUint32NetworkOrder(uint32_t value, std::vector<uint8_t>* bytes);
+
+// Writes the given unsigned 64-bit integer to the given vector in big endian
+// format.
+void WriteUint64NetworkOrder(uint64_t value, std::vector<uint8_t>* bytes);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_BYTE_WRITERS_H_
diff --git a/utils/byte_writers_test.cc b/utils/byte_writers_test.cc
new file mode 100644
index 0000000..d9331f7
--- /dev/null
+++ b/utils/byte_writers_test.cc
@@ -0,0 +1,69 @@
+// 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/byte_writers.h"
+
+#include <cstdint>
+#include <vector>
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+
+namespace {
+
+using ::testing::ElementsAre;
+
+TEST(ByteWritersTest, Write64Bit) {
+  std::vector<uint8_t> bytes;
+  WriteUint64NetworkOrder(0x1122334455667788, &bytes);
+  EXPECT_THAT(bytes,
+              ElementsAre(0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88));
+}
+
+TEST(ByteWritersTest, Write32Bit) {
+  std::vector<uint8_t> bytes;
+  WriteUint32NetworkOrder(0x11223344, &bytes);
+  EXPECT_THAT(bytes, ElementsAre(0x11, 0x22, 0x33, 0x44));
+}
+
+TEST(ByteWritersTest, Write16Bit) {
+  std::vector<uint8_t> bytes;
+  WriteUint16NetworkOrder(0x1234, &bytes);
+  EXPECT_THAT(bytes, ElementsAre(0x12, 0x34));
+}
+
+TEST(ByteWritersTest, Write64BitToNonEmptyVector) {
+  std::vector<uint8_t> bytes = {0xaa, 0xbb};
+  WriteUint64NetworkOrder(0x1122334455667788, &bytes);
+  EXPECT_THAT(bytes, ElementsAre(0xaa, 0xbb, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
+                                 0x77, 0x88));
+}
+
+TEST(ByteWritersTest, Write32BitToNonEmptyVector) {
+  std::vector<uint8_t> bytes = {0xaa, 0xbb};
+  WriteUint32NetworkOrder(0x11223344, &bytes);
+  EXPECT_THAT(bytes, ElementsAre(0xaa, 0xbb, 0x11, 0x22, 0x33, 0x44));
+}
+
+TEST(ByteWritersTest, Write16BitToNonEmptyVector) {
+  std::vector<uint8_t> bytes = {0xaa, 0xbb};
+  WriteUint16NetworkOrder(0x1234, &bytes);
+  EXPECT_THAT(bytes, ElementsAre(0xaa, 0xbb, 0x12, 0x34));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/distinguished_name.cc b/utils/distinguished_name.cc
new file mode 100644
index 0000000..9a4dfab
--- /dev/null
+++ b/utils/distinguished_name.cc
@@ -0,0 +1,234 @@
+// 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/distinguished_name.h"
+
+#include <cstddef>
+#include <string>
+#include <utility>
+
+#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/ascii.h"
+#include "absl/strings/escaping.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/strip.h"
+
+namespace credentio {
+namespace {
+
+// Consumes an OID.
+//
+// oid = 1*DIGIT *("." 1*DIGIT)
+// (but we only enforce that it starts with a digit and contains digits and ".")
+absl::StatusOr<std::string> ConsumeOid(absl::string_view* s) {
+  if (s->empty() || !absl::ascii_isdigit(s->front())) {
+    return absl::InvalidArgumentError("expected OID value starting with digit");
+  }
+  size_t len = s->find_first_not_of("0123456789.");
+  std::string oid = std::string(s->substr(0, len));
+  s->remove_prefix(oid.size());
+  return oid;
+}
+
+// Consumes an attribute type (e.g., "CN" or "1.2.3").
+//
+// attributeType = (ALPHA 1*keychar) / oid
+// keychar    = ALPHA / DIGIT / "-"
+absl::StatusOr<std::string> ConsumeAttr(absl::string_view* s) {
+  if (s->empty()) {
+    return absl::InvalidArgumentError("empty attribute name");
+  }
+  if (absl::ascii_isdigit(s->front())) {
+    return ConsumeOid(s);
+  }
+  if (!absl::ascii_isalpha(s->front())) {
+    return absl::InvalidArgumentError(
+        "expected attribute name starting with alpha");
+  }
+  std::string value;
+  while (!s->empty()) {
+    if (absl::ascii_isalpha(s->front()) || absl::ascii_isdigit(s->front()) ||
+        s->front() == '-') {
+      value.push_back(s->front());
+      s->remove_prefix(1);
+    } else {
+      break;
+    }
+  }
+  return value;
+}
+
+// Consumes a #aabbccddee string, returning decoded value.
+absl::StatusOr<std::string> ConsumeHexStringValue(absl::string_view* s) {
+  absl::string_view temp = *s;
+  if (!absl::ConsumePrefix(&temp, "#")) {
+    return absl::InvalidArgumentError("expected hex string starting with #");
+  }
+
+  absl::string_view digits =
+      temp.substr(0, temp.find_first_not_of("0123456789abcdefABCDEF"));
+  temp.remove_prefix(digits.length());
+  std::string value;
+  if (!absl::HexStringToBytes(digits, &value)) {
+    return absl::InvalidArgumentError("cannot parse hex string");
+  }
+  *s = temp;
+  return value;
+}
+
+// Consumes a backslash escape, returning decoded character.
+//
+// pair       = "\" ( special / "\" / QUOTATION / hexpair )
+// stringchar = <any character except one of special, "\" or QUOTATION >
+//
+// hexstring  = 1*hexpair
+// hexpair    = hexchar hexchar
+//
+// hexchar    = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"
+//              / "a" / "b" / "c" / "d" / "e" / "f"
+// special    = "," / "=" / "+" / "<" /  ">" / "#" / ";"
+absl::StatusOr<std::string> ConsumeBackslashEscape(absl::string_view* s) {
+  absl::string_view temp = *s;
+  if (!absl::ConsumePrefix(&temp, "\\")) {
+    return absl::InvalidArgumentError(
+        "expected backslash escape starting with \\");
+  }
+  if (temp.empty()) {
+    return absl::InvalidArgumentError("backslash at end of input");
+  }
+  if (absl::ascii_isxdigit(absl::ascii_toupper(temp.front()))) {
+    std::string decoded_hex;
+    if (temp.length() < 2 ||
+        !absl::HexStringToBytes(temp.substr(0, 2), &decoded_hex)) {
+      return absl::InvalidArgumentError("cannot decode hex backslash escape");
+    }
+    temp.remove_prefix(2);
+    *s = temp;
+    return decoded_hex;
+  }
+  if (!absl::StrContains(",=+<>#;\"\\", temp.front())) {
+    return absl::InvalidArgumentError("invalid backslash escape");
+  }
+  std::string value = std::string(temp.substr(0, 1));
+  temp.remove_prefix(1);
+  *s = temp;
+  return value;
+}
+
+// Consumes a double-quoted string, returning decoded value.
+// string     = ... / QUOTATION *( quotechar / pair ) QUOTATION ; only from v2
+// quotechar     = <any character except "\" or QUOTATION >
+absl::StatusOr<std::string> ConsumeQuotedStringValue(absl::string_view* s) {
+  absl::string_view temp = *s;
+  if (!absl::ConsumePrefix(&temp, "\"")) {
+    return absl::InvalidArgumentError(
+        "expected quoted string starting with \"");
+  }
+  std::string value;
+  while (!temp.empty()) {
+    if (absl::ConsumePrefix(&temp, "\"")) {
+      *s = temp;
+      return value;
+    }
+    if (temp.front() == '\\') {
+      ABSL_ASSIGN_OR_RETURN(auto unescaped, ConsumeBackslashEscape(&temp));
+      absl::StrAppend(&value, unescaped);
+    } else {
+      value.push_back(temp.front());
+      temp.remove_prefix(1);
+    }
+  }
+  return absl::InvalidArgumentError("closing '\"' not found");
+}
+
+// Consumes an attribute value and returns the decoded value.
+//
+// attributeValue = string
+// string     = *( stringchar / pair )
+//              / "#" hexstring
+//              / QUOTATION *( quotechar / pair ) QUOTATION ; only from v2
+absl::StatusOr<std::string> ConsumeValue(absl::string_view* s) {
+  if (!s->empty() && s->front() == '#') {
+    return ConsumeHexStringValue(s);
+  }
+  if (!s->empty() && s->front() == '"') {
+    return ConsumeQuotedStringValue(s);
+  }
+  absl::string_view temp = *s;
+  std::string value;
+  while (!temp.empty() && !absl::StrContains(",=+<>#;\"", temp.front())) {
+    if (temp.front() == '\\') {
+      ABSL_ASSIGN_OR_RETURN(auto unescaped, ConsumeBackslashEscape(&temp));
+      absl::StrAppend(&value, unescaped);
+    } else {
+      value.push_back(temp.front());
+      temp.remove_prefix(1);
+    }
+  }
+  *s = temp;
+  return value;
+}
+
+// Consumes an attribute/value (e.g., "FOO=Bar") and returns the pair.
+absl::StatusOr<std::pair<std::string, std::string>> ConsumeAttrAndValue(
+    absl::string_view* s) {
+  absl::string_view temp = *s;
+  ABSL_ASSIGN_OR_RETURN(auto attr, ConsumeAttr(&temp));
+  if (!absl::ConsumePrefix(&temp, "=")) {
+    return absl::InvalidArgumentError("expected '=' after attribute name");
+  }
+  ABSL_ASSIGN_OR_RETURN(auto value, ConsumeValue(&temp));
+  *s = temp;
+  return std::make_pair(attr, value);
+}
+
+}  // namespace
+
+absl::StatusOr<absl::flat_hash_map<std::string, std::string>>
+ParseDistinguishedName(absl::string_view distinguished_name) {
+  absl::flat_hash_map<std::string, std::string> attrs;
+  while (!distinguished_name.empty()) {
+    auto attr_and_value = ConsumeAttrAndValue(&distinguished_name);
+    if (!attr_and_value.ok()) {
+      return absl::Status(attr_and_value.status().code(),
+                          absl::StrCat("cannot parse RFC2253 DN: ",
+                                       attr_and_value.status().message()));
+    }
+    const auto& [attr, value] = *attr_and_value;
+    for (const auto& [existing_attr, _] : attrs) {
+      if (absl::EqualsIgnoreCase(existing_attr, attr)) {
+        return absl::InvalidArgumentError("Duplicate attribute in RFC2253 DN");
+      }
+    }
+    attrs[attr] = value;
+    if (distinguished_name.empty()) {
+      break;
+    }
+    if (absl::ConsumePrefix(&distinguished_name, "+")) {
+      return absl::UnimplementedError("multi-valued RDN not supported");
+    }
+    if (!absl::ConsumePrefix(&distinguished_name, ",")) {
+      return absl::InvalidArgumentError("Cannot parse RFC2253 DN");
+    }
+  }
+  return attrs;
+}
+
+}  // namespace credentio
diff --git a/utils/distinguished_name.h b/utils/distinguished_name.h
new file mode 100644
index 0000000..6d566ba
--- /dev/null
+++ b/utils/distinguished_name.h
@@ -0,0 +1,34 @@
+// 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_DISTINGUISHED_NAME_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_DISTINGUISHED_NAME_H_
+
+#include <string>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Parses an RFC 2253 DN value (e.g., "CN=SnazzyCamera 2.0,OU=Snazzy
+// Cams,O=SnazzyCo\, LLC,L=Somewhere,ST=MD,C=US").
+absl::StatusOr<absl::flat_hash_map<std::string, std::string>>
+ParseDistinguishedName(absl::string_view distinguished_name);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_DISTINGUISHED_NAME_H_
diff --git a/utils/distinguished_name_test.cc b/utils/distinguished_name_test.cc
new file mode 100644
index 0000000..f35b236
--- /dev/null
+++ b/utils/distinguished_name_test.cc
@@ -0,0 +1,136 @@
+// 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/distinguished_name.h"
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio::ParseDistinguishedName;
+using ::testing::IsEmpty;
+using ::testing::Pair;
+using ::testing::UnorderedElementsAre;
+
+TEST(ParseDn, EmptyString) {
+  EXPECT_THAT(ParseDistinguishedName(""), IsOkAndHolds(IsEmpty()));
+}
+
+TEST(ParseDn, SingleValue) {
+  EXPECT_THAT(ParseDistinguishedName("CN=Foo"),
+              IsOkAndHolds(UnorderedElementsAre(Pair("CN", "Foo"))));
+}
+
+TEST(ParseDn, MultipleValues) {
+  EXPECT_THAT(ParseDistinguishedName("CN=Foo,O=Gizmotron,OU=Widgets Division"),
+              IsOkAndHolds(UnorderedElementsAre(
+                  Pair("CN", "Foo"), Pair("O", "Gizmotron"),
+                  Pair("OU", "Widgets Division"))));
+}
+
+TEST(ParseDn, BackslashEscapeSequences) {
+  EXPECT_THAT(ParseDistinguishedName(
+                  R"(CN=Foo\2fBar\2FBaz,O=P\=NP?,OU=\57idgets Division)"),
+              IsOkAndHolds(UnorderedElementsAre(
+                  Pair("CN", R"(Foo/Bar/Baz)"), Pair("O", R"(P=NP?)"),
+                  Pair("OU", R"(Widgets Division)"))));
+}
+
+TEST(ParseDn, TrailingBackslash) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo\)"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, InvalidBackslashHex) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=F\9j)"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, UnrecognizedBackslashEscape) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=Fo\o)"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, MissingEquals) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo,OU)"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, DuplicateAttribute) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo,CN=Bar)"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, DuplicateAttributeCaseInsensitive) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo,cn=Bar)"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, QuotedString) {
+  EXPECT_THAT(
+      ParseDistinguishedName(
+          R"(CN="This &= That, Inc.",O="A,B,C,\58\2c\59\2c\5A\+")"),
+      IsOkAndHolds(UnorderedElementsAre(Pair("CN", R"(This &= That, Inc.)"),
+                                        Pair("O", R"(A,B,C,X,Y,Z+)"))));
+}
+
+TEST(ParseDn, MissingCloseQuote) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN="Foo)"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, OidAttribute) {
+  EXPECT_THAT(ParseDistinguishedName("CN=Foo,1.2.345=Bar"),
+              IsOkAndHolds(UnorderedElementsAre(Pair("CN", "Foo"),
+                                                Pair("1.2.345", "Bar"))));
+}
+
+TEST(ParseDn, InvalidAttributeName) {
+  EXPECT_THAT(ParseDistinguishedName(".CN=Foo"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, HexString) {
+  EXPECT_THAT(ParseDistinguishedName("CN=#466f6F"),
+              IsOkAndHolds(UnorderedElementsAre(Pair("CN", "Foo"))));
+}
+
+TEST(ParseDn, HexStringOddLength) {
+  EXPECT_THAT(ParseDistinguishedName("CN=#466f6"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, EmptyAttributeValues) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=,OU="",O=#)"),
+              IsOkAndHolds(UnorderedElementsAre(Pair("CN", ""), Pair("OU", ""),
+                                                Pair("O", ""))));
+}
+
+TEST(ParseDn, InvalidSeparator) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo>O=Bar)"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ParseDn, MultiValueUnsupported) {
+  EXPECT_THAT(ParseDistinguishedName(R"(CN=Foo+O=Bar)"),
+              StatusIs(absl::StatusCode::kUnimplemented));
+}
+
+}  // namespace
diff --git a/utils/dual_status_tracker.cc b/utils/dual_status_tracker.cc
new file mode 100644
index 0000000..d8a9b74
--- /dev/null
+++ b/utils/dual_status_tracker.cc
@@ -0,0 +1,135 @@
+// 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/dual_status_tracker.h"
+
+#include <memory>
+
+#include "absl/base/nullability.h"
+#include "absl/log/log.h"
+#include "absl/memory/memory.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "constants/status_codes.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "validator/result.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+namespace {
+
+SuccessStatusCode ConvertIngredientCodeToActiveCode(SuccessStatusCode code) {
+  switch (code) {
+    case SuccessStatusCode::kAssertionBmffHashMatch:
+      return SuccessStatusCode::kGoogleAssertionBmffHashMatch;
+    case SuccessStatusCode::kAssertionBoxesHashMatch:
+      return SuccessStatusCode::kGoogleAssertionBoxesHashMatch;
+    case SuccessStatusCode::kAssertionDataHashMatch:
+      return SuccessStatusCode::kGoogleAssertionDataHashMatch;
+    case SuccessStatusCode::kAssertionMultiAssetHashMatch:
+      return SuccessStatusCode::kGoogleAssertionMultiAssetHashMatch;
+    default:
+      LOG(DFATAL) << "Unexpected success code: " << code;
+      return code;
+  }
+}
+
+FailureStatusCode ConvertIngredientCodeToActiveCode(FailureStatusCode code) {
+  switch (code) {
+    case FailureStatusCode::kAssertionBmffHashMismatch:
+      return FailureStatusCode::kGoogleAssertionBmffHashMismatch;
+    case FailureStatusCode::kAssertionBoxesHashMismatch:
+      return FailureStatusCode::kGoogleAssertionBoxesHashMismatch;
+    case FailureStatusCode::kAssertionDataHashMismatch:
+      return FailureStatusCode::kGoogleAssertionDataHashMismatch;
+    case FailureStatusCode::kAssertionBmffHashMalformed:
+      return FailureStatusCode::kGoogleAssertionBmffHashMalformed;
+    case FailureStatusCode::kAssertionBoxesHashUnknownBox:
+      return FailureStatusCode::kGoogleAssertionBoxesHashUnknownBox;
+    case FailureStatusCode::kAssertionDataHashMalformed:
+      return FailureStatusCode::kGoogleAssertionDataHashMalformed;
+    case FailureStatusCode::kAssertionBoxesHashMalformed:
+      return FailureStatusCode::kGoogleAssertionBoxesHashMalformed;
+    case FailureStatusCode::kAssertionMultiAssetHashMismatch:
+      return FailureStatusCode::kGoogleAssertionMultiAssetHashMismatch;
+    case FailureStatusCode::kAssertionMultiAssetHashMalformed:
+      return FailureStatusCode::kGoogleAssertionMultiAssetHashMalformed;
+    case FailureStatusCode::kAssertionMultiAssetHashMissingPart:
+      return FailureStatusCode::kGoogleAssertionMultiAssetHashMissingPart;
+    default:
+      return code;
+  }
+}
+
+InformationalStatusCode ConvertIngredientCodeToActiveCode(
+    InformationalStatusCode code) {
+  switch (code) {
+    default:
+      return code;
+  }
+}
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<DualStatusTracker>>
+DualStatusTracker::FromPartialValidationResult(
+    PartialValidationResultProto* absl_nonnull partial_validation_result) {
+  Manifest* absl_nullable assertion_manifest = GetMutableManifestForAssertion(
+      partial_validation_result, partial_validation_result->hard_binding_uri());
+  if (assertion_manifest == nullptr) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("missing manifest for assertion: ",
+                     partial_validation_result->hard_binding_uri()));
+  }
+  Manifest* active_manifest =
+      partial_validation_result->mutable_active_manifest();
+  return absl::WrapUnique(new DualStatusTracker(
+      assertion_manifest,
+      assertion_manifest == active_manifest ? nullptr : active_manifest));
+}
+
+void DualStatusTracker::RecordSuccess(
+    SuccessStatusCode code, ValidationTracker::RecordOptions options) {
+  assertion_tracker_->RecordSuccess(code, options);
+  if (active_tracker_ != nullptr) {
+    active_tracker_->RecordSuccess(ConvertIngredientCodeToActiveCode(code),
+                                   options);
+  }
+}
+
+void DualStatusTracker::RecordFailure(
+    FailureStatusCode code, ValidationTracker::RecordOptions options) {
+  assertion_tracker_->RecordFailure(code, options);
+  if (active_tracker_ != nullptr) {
+    active_tracker_->RecordFailure(ConvertIngredientCodeToActiveCode(code),
+                                   options);
+  }
+}
+
+void DualStatusTracker::RecordInformational(
+    InformationalStatusCode code, ValidationTracker::RecordOptions options) {
+  assertion_tracker_->RecordInformational(code, options);
+  if (active_tracker_ != nullptr) {
+    active_tracker_->RecordInformational(
+        ConvertIngredientCodeToActiveCode(code), options);
+  }
+}
+
+}  // namespace credentio
diff --git a/utils/dual_status_tracker.h b/utils/dual_status_tracker.h
new file mode 100644
index 0000000..e70e593
--- /dev/null
+++ b/utils/dual_status_tracker.h
@@ -0,0 +1,86 @@
+// 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_DUAL_STATUS_TRACKER_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_DUAL_STATUS_TRACKER_H_
+
+#include <memory>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/status/statusor.h"
+#include "constants/status_codes.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "utils/status_tracker.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+// A tracker for writing hard binding success and failure codes to the manifest
+// containing the hard binding assertion and optionally the active manifest.
+//
+// The success and failure codes are written to the manifest containing the
+// assertion using the provided ValidationTracker and, if the manifest
+// containing the assertion is not the active manifest, optionally to the active
+// manifest using the optional ValidationTracker.
+//
+// The success and failure codes are written to the active manifest using a
+// google-code prefixed version of the c2pa success/failure code. This is
+// necessary because the summarizer only reads from the active manifest and
+// needs the success/failure codes to know which hard binding assertions were
+// evaluated.
+class DualStatusTracker : public StatusTracker {
+ public:
+  static absl::StatusOr<std::unique_ptr<DualStatusTracker>>
+  FromPartialValidationResult(
+      PartialValidationResultProto* absl_nonnull partial_validation_result);
+
+  void RecordSuccess(SuccessStatusCode code,
+                     ValidationTracker::RecordOptions options) override;
+  void RecordFailure(FailureStatusCode code,
+                     ValidationTracker::RecordOptions options) override;
+  void RecordInformational(InformationalStatusCode code,
+                           ValidationTracker::RecordOptions options) override;
+
+  bool WritesToIngredientManifest() const { return active_tracker_ != nullptr; }
+
+ private:
+  // The tracker for the manifest containing the hard binding assertion.
+  std::unique_ptr<ValidationTracker> assertion_tracker_;
+  // The tracker for the active manifest if the assertion is not in the active
+  // manifest.
+  std::unique_ptr<ValidationTracker> active_tracker_;
+
+  explicit DualStatusTracker(Manifest* absl_nonnull assertion_manifest,
+                             Manifest* absl_nullable active_manifest)
+      : assertion_tracker_(
+            std::make_unique<ValidationTracker>(assertion_manifest)),
+        active_tracker_(
+            active_manifest == nullptr
+                ? nullptr
+                : std::make_unique<ValidationTracker>(active_manifest)) {};
+  // This constructor should only be used by the static factory methods.
+  explicit DualStatusTracker(
+      std::unique_ptr<ValidationTracker> absl_nonnull assertion_tracker,
+      std::unique_ptr<ValidationTracker> absl_nullable active_tracker = nullptr)
+      : assertion_tracker_(std::move(assertion_tracker)),
+        active_tracker_(std::move(active_tracker)) {};
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_DUAL_STATUS_TRACKER_H_
diff --git a/utils/dual_status_tracker_test.cc b/utils/dual_status_tracker_test.cc
new file mode 100644
index 0000000..0c8c308
--- /dev/null
+++ b/utils/dual_status_tracker_test.cc
@@ -0,0 +1,364 @@
+// 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/dual_status_tracker.h"
+
+#include <string>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/assertion.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+using ::testing::TestParamInfo;
+using ::testing::TestWithParam;
+
+struct TrackerTestCase {
+  std::string test_name;
+  credentio::SuccessStatusCode success_code;
+  credentio::FailureStatusCode failure_code;
+  std::string expected_success_codes;
+  std::string expected_failure_codes;
+};
+using TrackerTest = TestWithParam<TrackerTestCase>;
+
+absl::flat_hash_set<std::string> GetFailures(ValidationStatusSet validation) {
+  absl::flat_hash_set<std::string> failures;
+  for (const auto& failure : validation.failures()) {
+    failures.insert(std::string(failure.code()));
+  }
+  return failures;
+}
+
+absl::flat_hash_set<std::string> GetSuccesses(ValidationStatusSet validation) {
+  absl::flat_hash_set<std::string> successes;
+  for (const auto& success : validation.successes()) {
+    successes.insert(std::string(success.code()));
+  }
+  return successes;
+}
+
+TEST(DualStatusTrackerTest, ManifestNotFound) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "a" }
+    hard_binding_uri: "self#jumbf=/c2pa/1234/c2pa.assertions/c2pa.hash.boxes"
+  )pb");
+  EXPECT_THAT(DualStatusTracker::FromPartialValidationResult(&result),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(DualStatusTrackerTest, NoHardBindingUri) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "a" }
+  )pb");
+  EXPECT_THAT(DualStatusTracker::FromPartialValidationResult(&result),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(DualStatusTrackerTest, WritesStatusCodesToSingleTracker) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "a" }
+    hard_binding_uri: "self#jumbf=/c2pa/a/c2pa.assertions/c2pa.hash.boxes"
+  )pb");
+
+  auto tracker = DualStatusTracker::FromPartialValidationResult(&result);
+  ASSERT_TRUE(tracker.ok());
+  const auto& active = result.active_manifest();
+
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+
+  (*tracker)->RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {});
+  EXPECT_THAT(GetSuccesses(active.validation()),
+              ElementsAre("assertion.bmffHash.match"));
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+
+  (*tracker)->RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, {});
+  EXPECT_THAT(GetSuccesses(active.validation()),
+              ElementsAre("assertion.bmffHash.match"));
+  EXPECT_THAT(GetFailures(active.validation()),
+              ElementsAre("assertion.bmffHash.mismatch"));
+}
+
+TEST(DualStatusTrackerTest, WritesDifferentSuccessCode) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "a" }
+    ingredient_manifests { label: "i" }
+    hard_binding_uri: "self#jumbf=/c2pa/i/c2pa.assertions/c2pa.hash.boxes"
+  )pb");
+
+  auto tracker = DualStatusTracker::FromPartialValidationResult(&result);
+  ASSERT_TRUE(tracker.ok());
+
+  const auto& active = result.active_manifest();
+  const auto& ingredient = result.ingredient_manifests(0);
+
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+  EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty());
+
+  (*tracker)->RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {});
+  EXPECT_THAT(GetSuccesses(active.validation()),
+              ElementsAre("com.google.assertion.bmffHash.match"));
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+  EXPECT_THAT(GetSuccesses(ingredient.validation()),
+              ElementsAre("assertion.bmffHash.match"));
+  EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty());
+}
+
+TEST(DualStatusTrackerTest, WritesSameFailureCode) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "a" }
+    ingredient_manifests { label: "i" }
+    hard_binding_uri: "self#jumbf=/c2pa/i/c2pa.assertions/c2pa.hash.boxes"
+  )pb");
+
+  auto tracker = DualStatusTracker::FromPartialValidationResult(&result);
+  ASSERT_TRUE(tracker.ok());
+
+  const auto& active = result.active_manifest();
+  const auto& ingredient = result.ingredient_manifests(0);
+
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+  EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty());
+
+  (*tracker)->RecordFailure(FailureStatusCode::kClaimCborInvalid, {});
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()),
+              ElementsAre("claim.cbor.invalid"));
+  EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(ingredient.validation()),
+              ElementsAre("claim.cbor.invalid"));
+}
+
+TEST(DualStatusTrackerTest, WritesDifferentFailureCode) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "a" }
+    ingredient_manifests { label: "i" }
+    hard_binding_uri: "self#jumbf=/c2pa/i/c2pa.assertions/c2pa.hash.boxes"
+  )pb");
+
+  auto tracker = DualStatusTracker::FromPartialValidationResult(&result);
+  ASSERT_TRUE(tracker.ok());
+
+  const auto& active = result.active_manifest();
+  const auto& ingredient = result.ingredient_manifests(0);
+
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+  EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty());
+
+  (*tracker)->RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, {});
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()),
+              ElementsAre("com.google.assertion.bmffHash.mismatch"));
+  EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(ingredient.validation()),
+              ElementsAre("assertion.bmffHash.mismatch"));
+}
+
+TEST(DualStatusTrackerTest, StatusUrls) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "a" }
+    ingredient_manifests { label: "i" }
+    hard_binding_uri: "self#jumbf=/c2pa/i/c2pa.assertions/c2pa.hash.boxes"
+  )pb");
+
+  auto tracker = DualStatusTracker::FromPartialValidationResult(&result);
+  ASSERT_TRUE(tracker.ok());
+
+  const auto& active = result.active_manifest();
+  const auto& ingredient = result.ingredient_manifests(0);
+
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+  EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(ingredient.validation()), IsEmpty());
+
+  (*tracker)->RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch, {});
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()),
+              ElementsAre("com.google.assertion.bmffHash.mismatch"));
+  EXPECT_THAT(GetSuccesses(ingredient.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(ingredient.validation()),
+              ElementsAre("assertion.bmffHash.mismatch"));
+
+  EXPECT_THAT(active.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(
+                  R"pb(
+                    failures {
+                      code: "com.google.assertion.bmffHash.mismatch"
+                      url: "self#jumbf=/c2pa/a"
+                    }
+                  )pb")));
+  EXPECT_THAT(ingredient.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(
+                  R"pb(
+                    failures {
+                      code: "assertion.bmffHash.mismatch"
+                      url: "self#jumbf=/c2pa/i"
+                    }
+                  )pb")));
+}
+
+TEST(DualStatusTrackerTest, WritesUnexpectedSuccessCode) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "a" }
+    hard_binding_uri: "self#jumbf=/c2pa/a/c2pa.assertions/c2pa.hash.boxes"
+  )pb");
+
+  auto tracker = DualStatusTracker::FromPartialValidationResult(&result);
+  ASSERT_TRUE(tracker.ok());
+  const auto& active = result.active_manifest();
+
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+
+  (*tracker)->RecordSuccess(SuccessStatusCode::kClaimSignatureValidated, {});
+  EXPECT_THAT(GetSuccesses(active.validation()),
+              ElementsAre("claimSignature.validated"));
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+}
+
+// Parametrized tests for success and failure codes.
+TEST_P(TrackerTest, WritesStatusCodesToSingleTrackerAssertionsInActive) {
+  const TrackerTestCase& test_case = GetParam();
+
+  PartialValidationResultProto result;
+  result.mutable_active_manifest()->set_label("active_manifest");
+  result.mutable_active_manifest()->add_assertions()->set_label("assertion1");
+  result.set_hard_binding_uri(
+      "self#jumbf=/c2pa/active_manifest/c2pa.assertions/assertion1");
+
+  auto tracker = DualStatusTracker::FromPartialValidationResult(&result);
+  ASSERT_TRUE(tracker.ok());
+  const auto& active = result.active_manifest();
+
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+
+  (*tracker)->RecordSuccess(test_case.success_code, {});
+  EXPECT_THAT(GetSuccesses(active.validation()),
+              ElementsAre(test_case.expected_success_codes));
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+
+  (*tracker)->RecordFailure(test_case.failure_code, {});
+  EXPECT_THAT(GetSuccesses(active.validation()),
+              ElementsAre(test_case.expected_success_codes));
+  EXPECT_THAT(GetFailures(active.validation()),
+              ElementsAre(test_case.expected_failure_codes));
+}
+
+TEST_P(TrackerTest, WritesStatusCodesToSingleTrackerAssertionsInIngredient) {
+  const TrackerTestCase& test_case = GetParam();
+
+  PartialValidationResultProto result;
+  result.mutable_active_manifest()->set_label("active_manifest");
+  result.add_ingredient_manifests()->set_label("ingredient_manifest");
+  result.mutable_ingredient_manifests(0)->add_assertions()->set_label(
+      "assertion1");
+  result.set_hard_binding_uri(
+      "self#jumbf=/c2pa/ingredient_manifest/c2pa.assertions/assertion1");
+
+  auto tracker = DualStatusTracker::FromPartialValidationResult(&result);
+  ASSERT_TRUE(tracker.ok());
+  const auto& active = result.active_manifest();
+
+  // It is expected to have "com.google." prefixes here.
+  std::string expected_success_codes =
+      absl::StrCat("com.google.", test_case.expected_success_codes);
+  std::string expected_failure_codes =
+      absl::StrCat("com.google.", test_case.expected_failure_codes);
+  EXPECT_THAT(GetSuccesses(active.validation()), IsEmpty());
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+
+  (*tracker)->RecordSuccess(test_case.success_code, {});
+  EXPECT_THAT(GetSuccesses(active.validation()),
+              ElementsAre(expected_success_codes));
+  EXPECT_THAT(GetFailures(active.validation()), IsEmpty());
+
+  (*tracker)->RecordFailure(test_case.failure_code, {});
+  EXPECT_THAT(GetSuccesses(active.validation()),
+              ElementsAre(expected_success_codes));
+  EXPECT_THAT(GetFailures(active.validation()),
+              ElementsAre(expected_failure_codes));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    TrackerTest, TrackerTest,
+    testing::ValuesIn<TrackerTestCase>({
+        {"BmffWithMismatch", SuccessStatusCode::kAssertionBmffHashMatch,
+         FailureStatusCode::kAssertionBmffHashMismatch,
+         "assertion.bmffHash.match", "assertion.bmffHash.mismatch"},
+        {"BmffWithMalformed", SuccessStatusCode::kAssertionBmffHashMatch,
+         FailureStatusCode::kAssertionBmffHashMalformed,
+         "assertion.bmffHash.match", "assertion.bmffHash.malformed"},
+        {"BoxesWithMismatch", SuccessStatusCode::kAssertionBoxesHashMatch,
+         FailureStatusCode::kAssertionBoxesHashMismatch,
+         "assertion.boxesHash.match", "assertion.boxesHash.mismatch"},
+        {"BoxesWithUnknown", SuccessStatusCode::kAssertionBoxesHashMatch,
+         FailureStatusCode::kAssertionBoxesHashUnknownBox,
+         "assertion.boxesHash.match", "assertion.boxesHash.unknownBox"},
+        {"BoxesWithMalformed", SuccessStatusCode::kAssertionBoxesHashMatch,
+         FailureStatusCode::kAssertionBoxesHashMalformed,
+         "assertion.boxesHash.match", "assertion.boxesHash.malformed"},
+        {"DataWithMismatch", SuccessStatusCode::kAssertionDataHashMatch,
+         FailureStatusCode::kAssertionDataHashMismatch,
+         "assertion.dataHash.match", "assertion.dataHash.mismatch"},
+        {"DataWithMalformed", SuccessStatusCode::kAssertionDataHashMatch,
+         FailureStatusCode::kAssertionDataHashMalformed,
+         "assertion.dataHash.match", "assertion.dataHash.malformed"},
+        {"MultiAssetWithMalformed",
+         SuccessStatusCode::kAssertionMultiAssetHashMatch,
+         FailureStatusCode::kAssertionMultiAssetHashMalformed,
+         "assertion.multiAssetHash.match",
+         "assertion.multiAssetHash.malformed"},
+        {"MultiAssetWithMismatch",
+         SuccessStatusCode::kAssertionMultiAssetHashMatch,
+         FailureStatusCode::kAssertionMultiAssetHashMismatch,
+         "assertion.multiAssetHash.match", "assertion.multiAssetHash.mismatch"},
+        {"MultiAssetWithMissingPart",
+         SuccessStatusCode::kAssertionMultiAssetHashMatch,
+         FailureStatusCode::kAssertionMultiAssetHashMissingPart,
+         "assertion.multiAssetHash.match",
+         "assertion.multiAssetHash.missingPart"},
+    }),
+    [](const TestParamInfo<TrackerTest::ParamType>& info) {
+      return info.param.test_name;
+    });
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/media_type.cc b/utils/media_type.cc
new file mode 100644
index 0000000..5ecd6be
--- /dev/null
+++ b/utils/media_type.cc
@@ -0,0 +1,81 @@
+// 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/media_type.h"
+
+#include <string>
+
+#include "absl/base/no_destructor.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/ascii.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+namespace {
+
+// Returns the part of the filename after the final ".", or an empty string if
+// the filename does not have a ".".
+absl::string_view GetFileExtension(absl::string_view filename) {
+  auto dot_pos = filename.rfind('.');
+  if (dot_pos == absl::string_view::npos) return "";
+  return filename.substr(dot_pos + 1);
+}
+
+}  // namespace
+
+absl::StatusOr<std::string> MediaType(absl::string_view filename) {
+  static absl::NoDestructor<absl::flat_hash_map<std::string, std::string>>
+      kExtensionToMediaType({
+          {"avif", "image/avif"},
+          {"heic", "image/heic"},
+          {"heif", "image/heif"},
+          {"jpeg", "image/jpeg"},
+          {"jpg", "image/jpeg"},
+          {"m4a", "audio/mp4"},
+          {"mp4", "video/mp4"},
+          {"pdf", "application/pdf"},
+          {"png", "image/png"},
+          {"webp", "image/webp"},
+          {"mov", "video/quicktime"},
+          {"dng", "image/x-adobe-dng"},
+          {"tif", "image/tiff"},
+          {"tiff", "image/tiff"},
+          {"wav", "audio/wav"},  // Not formally registered.
+          {"gif", "image/gif"},
+          {"mp3", "audio/mpeg"},
+          {"flac", "audio/flac"},
+          {"pptx",
+           "application/"
+           "vnd.openxmlformats-officedocument.presentationml.presentation"},
+          {"docx",
+           "application/"
+           "vnd.openxmlformats-officedocument.wordprocessingml.document"},
+          {"xlsx",
+           "application/"
+           "vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
+      });
+  std::string extension = absl::AsciiStrToLower(GetFileExtension(filename));
+  auto it = kExtensionToMediaType->find(extension);
+  if (it != kExtensionToMediaType->end()) {
+    return it->second;
+  }
+  return absl::InvalidArgumentError(
+      absl::StrCat("Unsupported file extension: \"", extension, "\""));
+}
+
+}  // namespace credentio
diff --git a/utils/media_type.h b/utils/media_type.h
new file mode 100644
index 0000000..1f4e96f
--- /dev/null
+++ b/utils/media_type.h
@@ -0,0 +1,31 @@
+// 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_MEDIA_TYPE_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_MEDIA_TYPE_H_
+
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Returns the media type of the given filename.
+absl::StatusOr<std::string> MediaType(absl::string_view filename);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_MEDIA_TYPE_H_
diff --git a/utils/media_type_test.cc b/utils/media_type_test.cc
new file mode 100644
index 0000000..9b75dda
--- /dev/null
+++ b/utils/media_type_test.cc
@@ -0,0 +1,73 @@
+// 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/media_type.h"
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+
+TEST(MediaTypeTest, ReturnsMediaType) {
+  EXPECT_THAT(MediaType("foo.avif"), IsOkAndHolds("image/avif"));
+  EXPECT_THAT(MediaType("foo.heic"), IsOkAndHolds("image/heic"));
+  EXPECT_THAT(MediaType("foo.heif"), IsOkAndHolds("image/heif"));
+  EXPECT_THAT(MediaType("foo.jpeg"), IsOkAndHolds("image/jpeg"));
+  EXPECT_THAT(MediaType("foo.jpg"), IsOkAndHolds("image/jpeg"));
+  EXPECT_THAT(MediaType("foo.mp4"), IsOkAndHolds("video/mp4"));
+  EXPECT_THAT(MediaType("foo.pdf"), IsOkAndHolds("application/pdf"));
+  EXPECT_THAT(MediaType("foo.png"), IsOkAndHolds("image/png"));
+  EXPECT_THAT(MediaType("foo.webp"), IsOkAndHolds("image/webp"));
+  EXPECT_THAT(MediaType("foo.mov"), IsOkAndHolds("video/quicktime"));
+  EXPECT_THAT(MediaType("foo.dng"), IsOkAndHolds("image/x-adobe-dng"));
+  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.gif"), IsOkAndHolds("image/gif"));
+  EXPECT_THAT(MediaType("foo.m4a"), IsOkAndHolds("audio/mp4"));
+  EXPECT_THAT(MediaType("foo.mp3"), IsOkAndHolds("audio/mpeg"));
+  EXPECT_THAT(MediaType("foo.flac"), IsOkAndHolds("audio/flac"));
+  EXPECT_THAT(
+      MediaType("foo.pptx"),
+      IsOkAndHolds(
+          "application/"
+          "vnd.openxmlformats-officedocument.presentationml.presentation"));
+  EXPECT_THAT(
+      MediaType("foo.docx"),
+      IsOkAndHolds(
+          "application/"
+          "vnd.openxmlformats-officedocument.wordprocessingml.document"));
+  EXPECT_THAT(
+      MediaType("foo.xlsx"),
+      IsOkAndHolds("application/"
+                   "vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
+
+  EXPECT_THAT(MediaType("FOO.AVIF"), IsOkAndHolds("image/avif"));
+
+  EXPECT_THAT(MediaType("no_extension"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(MediaType(""), StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(MediaType("foo.txt"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/riegeli.cc b/utils/riegeli.cc
new file mode 100644
index 0000000..7e4325b
--- /dev/null
+++ b/utils/riegeli.cc
@@ -0,0 +1,54 @@
+// 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/riegeli.h"
+
+#include <algorithm>
+#include <cstring>
+#include <string>
+
+#include "riegeli/base/arithmetic.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+bool ReadNullTerminatedString(riegeli::Reader& reader, size_t max_length,
+                              std::string& output) {
+  reader.Pull(max_length);
+  if (reader.available() == 0) {
+    // The data ends. Avoid undefined behavior in `std::memchr(nullptr, _, _)`
+    // if `reader.cursor() == nullptr`.
+    return false;
+  }
+  const char* const terminator = static_cast<const char*>(std::memchr(
+      reader.cursor(), '\0', std::min(reader.available(), max_length)));
+  if (terminator == nullptr) {
+    // No null terminator found within the max_length.
+    return false;
+  }
+
+  size_t length = riegeli::PtrDistance(reader.cursor(), terminator);
+  if (!reader.Read(length, output)) {
+    // Failed to read the string.
+    return false;
+  }
+  if (!reader.Skip(1)) {
+    // Failed to skip the null terminator.
+    return false;
+  }
+  return true;
+}
+
+}  // namespace credentio
diff --git a/utils/riegeli.h b/utils/riegeli.h
new file mode 100644
index 0000000..f36bfec
--- /dev/null
+++ b/utils/riegeli.h
@@ -0,0 +1,69 @@
+// 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_RIEGELI_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_RIEGELI_H_
+
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_reading.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+
+// Reads a single value of type `T` from the source in big endian order, adds
+// the adjustment value and writes it to the destination. Returns the adjusted
+// number. An error is returned if the adjusted value is too large to fit in a
+// `T` or if either reading or writing fails.
+template <typename T>
+absl::StatusOr<T> CopyBigEndian(riegeli::Reader& source,
+                                riegeli::Writer& destination,
+                                uint64_t adjustment = 0) {
+  T offset;
+  if (!riegeli::ReadBigEndian<T>(source, offset)) {
+    return source.StatusOrAnnotate(absl::DataLossError(
+        absl::StrCat("Failed to read unsigned integer of size: ", sizeof(T))));
+  }
+
+  T space_remaining = std::numeric_limits<T>::max() - offset;
+  if (space_remaining < adjustment) {
+    return absl::DataLossError("The adjusted offset is too large to fit");
+  }
+
+  offset += adjustment;
+
+  if (!riegeli::WriteBigEndian<T>(offset, destination)) {
+    return destination.status();
+  }
+  return offset;
+}
+
+// Reads a null terminated string from the source into the output string.
+// Returns false if the read or seek fails or if no null terminator was
+// encountered within the given max_length. Returns true otherwise.
+bool ReadNullTerminatedString(riegeli::Reader& reader, size_t max_length,
+                              std::string& output);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_RIEGELI_H_
diff --git a/utils/riegeli_test.cc b/utils/riegeli_test.cc
new file mode 100644
index 0000000..640d730
--- /dev/null
+++ b/utils/riegeli_test.cc
@@ -0,0 +1,234 @@
+// 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/riegeli.h"
+
+#include <cstdint>
+#include <limits>
+#include <string>
+#include <type_traits>
+
+#include "absl/functional/function_ref.h"
+#include "absl/log/log.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/test_string_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+template <typename T>
+using CreateStringFunction = absl::FunctionRef<std::string(T)>;
+
+template <typename T>
+absl::StatusOr<CreateStringFunction<T>> GetCreateStringFunction() {
+  if (!std::is_unsigned_v<T>) {
+    return absl::InvalidArgumentError("Type must be unsigned");
+  }
+
+  switch (sizeof(T)) {
+    case 1:
+      return credentio_testing::Uint8Str;
+    case 2:
+      return credentio_testing::Uint16Str;
+    case 4:
+      return credentio_testing::Uint32Str;
+    case 8:
+      return credentio_testing::Uint64Str;
+  }
+
+  return absl::InvalidArgumentError("Unsupported type size");
+}
+
+template <typename T>
+using CopyBigEndianTests = testing::Test;
+
+TYPED_TEST_SUITE_P(CopyBigEndianTests);
+
+TYPED_TEST_P(CopyBigEndianTests, FailsWhenSourceClosed) {
+  std::string source_str = "";
+  riegeli::StringReader<> source_reader(source_str);
+
+  std::string result;
+  riegeli::StringWriter<> destination(&result);
+
+  source_reader.Close();
+
+  EXPECT_THAT(CopyBigEndian<TypeParam>(source_reader, destination, 0),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       HasSubstr("Object closed")));
+
+  destination.Close();
+  EXPECT_EQ(result, "");
+}
+
+TYPED_TEST_P(CopyBigEndianTests, FailsWhenDestinationClosed) {
+  auto create_string = GetCreateStringFunction<TypeParam>();
+  ASSERT_TRUE(create_string.ok());
+
+  std::string source_str = (*create_string)(10);
+  riegeli::StringReader<> source_reader(source_str);
+
+  std::string result;
+  riegeli::StringWriter<> destination(&result);
+
+  destination.Close();
+
+  EXPECT_THAT(CopyBigEndian<TypeParam>(source_reader, destination, 0),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       HasSubstr("Object closed")));
+}
+
+TYPED_TEST_P(CopyBigEndianTests, FailsWhenNoSource) {
+  std::string source_str = "";
+  riegeli::StringReader<> source_reader(source_str);
+
+  std::string result;
+  riegeli::StringWriter<> destination(&result);
+
+  EXPECT_THAT(
+      CopyBigEndian<TypeParam>(source_reader, destination, 0),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read")));
+
+  destination.Close();
+  EXPECT_EQ(result, "");
+}
+
+TYPED_TEST_P(CopyBigEndianTests, FailsWhenTooLarge) {
+  auto create_string = GetCreateStringFunction<TypeParam>();
+  ASSERT_TRUE(create_string.ok());
+
+  std::string source_str =
+      (*create_string)(std::numeric_limits<TypeParam>().max() - 10);
+  riegeli::StringReader<> source_reader(source_str);
+
+  std::string result;
+  riegeli::StringWriter<> destination(&result);
+
+  EXPECT_THAT(CopyBigEndian<TypeParam>(source_reader, destination,
+                                       std::numeric_limits<int64_t>().max()),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("adjusted offset is too large")));
+
+  destination.Close();
+  EXPECT_EQ(result, "");
+}
+
+TYPED_TEST_P(CopyBigEndianTests, Succeeds) {
+  auto create_string = GetCreateStringFunction<TypeParam>();
+  ASSERT_TRUE(create_string.ok());
+
+  TypeParam source = 10;
+  std::string source_str = (*create_string)(source);
+  riegeli::StringReader<> source_reader(source_str);
+
+  int64_t adjustment = 10;
+
+  TypeParam expected = source + adjustment;
+  std::string expected_str = (*create_string)(expected);
+
+  std::string result;
+  riegeli::StringWriter<> destination(&result);
+
+  EXPECT_THAT(CopyBigEndian<TypeParam>(source_reader, destination, adjustment),
+              IsOkAndHolds(expected));
+
+  EXPECT_TRUE(destination.Close());
+  EXPECT_EQ(result, expected_str);
+}
+
+TYPED_TEST_P(CopyBigEndianTests, SucceedsWithZeroAdjustment) {
+  auto create_string = GetCreateStringFunction<TypeParam>();
+  ASSERT_TRUE(create_string.ok());
+
+  TypeParam source = 42;
+  std::string source_str = (*create_string)(source);
+  riegeli::StringReader<> source_reader(source_str);
+
+  std::string result;
+  riegeli::StringWriter<> destination(&result);
+
+  EXPECT_THAT(
+      credentio::CopyBigEndian<TypeParam>(source_reader, destination, 0),
+      IsOkAndHolds(source));
+
+  EXPECT_TRUE(destination.Close());
+  EXPECT_EQ(result, source_str);
+}
+
+TEST(ReadNullTerminatedStringTests, SucceedsWithNullTerminator) {
+  std::string source_str("hello\0world", 11);
+  riegeli::StringReader<> reader(source_str);
+  std::string output;
+  EXPECT_TRUE(ReadNullTerminatedString(reader, 10, output));
+  EXPECT_EQ(output, "hello");
+  EXPECT_EQ(reader.pos(), 6);
+}
+
+TEST(ReadNullTerminatedStringTests, SucceedsWithNullTerminatorAtStart) {
+  std::string source_str("\0helloworld", 11);
+  riegeli::StringReader<> reader(source_str);
+  std::string output;
+  EXPECT_TRUE(ReadNullTerminatedString(reader, 6, output));
+  EXPECT_EQ(output, "");
+  EXPECT_EQ(reader.pos(), 1);
+}
+
+TEST(ReadNullTerminatedStringTests, SucceedsWithMultipleNulls) {
+  std::string source_str("abc\0def\0ghi", 11);
+  riegeli::StringReader<> reader(source_str);
+  std::string output;
+  EXPECT_TRUE(ReadNullTerminatedString(reader, 8, output));
+  EXPECT_EQ(output, "abc");
+  EXPECT_EQ(reader.pos(), 4);
+}
+
+TEST(ReadNullTerminatedStringTests, FailsWithoutNullTerminatorWithinBounds) {
+  std::string source_str = "abcdefghij";
+  riegeli::StringReader<> reader(source_str);
+  std::string output;
+  EXPECT_FALSE(ReadNullTerminatedString(reader, 5, output));
+  EXPECT_EQ(output, "");
+  EXPECT_EQ(reader.pos(), 0);
+}
+
+TEST(ReadNullTerminatedStringTests, FailsIfInsufficientBytesToRead) {
+  std::string source_str = "abc";
+  riegeli::StringReader<> reader(source_str);
+  std::string output;
+  EXPECT_FALSE(ReadNullTerminatedString(reader, 5, output));
+}
+
+REGISTER_TYPED_TEST_SUITE_P(CopyBigEndianTests, FailsWhenSourceClosed,
+                            FailsWhenDestinationClosed, FailsWhenNoSource,
+                            FailsWhenTooLarge, Succeeds,
+                            SucceedsWithZeroAdjustment);
+
+using CopyBigEndianTypes =
+    ::testing::Types<uint8_t, uint16_t, uint32_t, uint64_t>;
+
+INSTANTIATE_TYPED_TEST_SUITE_P(_, CopyBigEndianTests, CopyBigEndianTypes);
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/status_tracker.h b/utils/status_tracker.h
new file mode 100644
index 0000000..d32a606
--- /dev/null
+++ b/utils/status_tracker.h
@@ -0,0 +1,55 @@
+// 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_STATUS_TRACKER_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_STATUS_TRACKER_H_
+
+#include "constants/status_codes.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+// A tracker for writing hard binding success and failure codes to the manifest
+// containing the hard binding assertion and optionally the active manifest.
+//
+// The success and failure codes are written to the manifest containing the
+// assertion using the provided ValidationTracker and, if the manifest
+// containing the assertion is not the active manifest, optionally to the active
+// manifest using the optional ValidationTracker.
+//
+// The success and failure codes are written to the active manifest using a
+// google-code prefixed version of the c2pa success/failure code. This is
+// necessary because the summarizer only reads from the active manifest and
+// needs the success/failure codes to know which hard binding assertions were
+// evaluated.
+class StatusTracker {
+ public:
+  virtual ~StatusTracker() = default;
+
+  virtual void RecordSuccess(SuccessStatusCode code,
+                             ValidationTracker::RecordOptions options) = 0;
+  virtual void RecordFailure(FailureStatusCode code,
+                             ValidationTracker::RecordOptions options) = 0;
+  virtual void RecordInformational(
+      InformationalStatusCode code,
+      ValidationTracker::RecordOptions options) = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_STATUS_TRACKER_H_
diff --git a/utils/two_stage_status_tracker.cc b/utils/two_stage_status_tracker.cc
new file mode 100644
index 0000000..ef13557
--- /dev/null
+++ b/utils/two_stage_status_tracker.cc
@@ -0,0 +1,67 @@
+// 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/two_stage_status_tracker.h"
+
+#include <utility>
+
+#include "constants/status_codes.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+void TwoStageStatusTracker::RecordSuccess(
+    SuccessStatusCode code, ValidationTracker::RecordOptions options) {
+  stage_1_success_.push_back(std::make_pair(code, SavedRecordOptions(options)));
+}
+
+void TwoStageStatusTracker::RecordFailure(
+    FailureStatusCode code, ValidationTracker::RecordOptions options) {
+  stage_1_failure_.push_back(std::make_pair(code, SavedRecordOptions(options)));
+}
+
+void TwoStageStatusTracker::RecordInformational(
+    InformationalStatusCode code, ValidationTracker::RecordOptions options) {
+  stage_1_informational_.push_back(
+      std::make_pair(code, SavedRecordOptions(options)));
+}
+
+void TwoStageStatusTracker::WriteStage1() {
+  for (const auto& [code, options] : stage_1_success_) {
+    hash_tracker_.RecordSuccess(code, options.Get());
+  }
+  for (const auto& [code, options] : stage_1_failure_) {
+    hash_tracker_.RecordFailure(code, options.Get());
+  }
+  for (const auto& [code, options] : stage_1_informational_) {
+    hash_tracker_.RecordInformational(code, options.Get());
+  }
+  ClearStage1();
+}
+
+void TwoStageStatusTracker::WriteStage2() {
+  for (const auto& [code, options] : stage_2_success_) {
+    hash_tracker_.RecordSuccess(code, options.Get());
+  }
+  for (const auto& [code, options] : stage_2_failure_) {
+    hash_tracker_.RecordFailure(code, options.Get());
+  }
+  for (const auto& [code, options] : stage_2_informational_) {
+    hash_tracker_.RecordInformational(code, options.Get());
+  }
+  ClearStage2();
+}
+
+}  // namespace credentio
diff --git a/utils/two_stage_status_tracker.h b/utils/two_stage_status_tracker.h
new file mode 100644
index 0000000..3ff3165
--- /dev/null
+++ b/utils/two_stage_status_tracker.h
@@ -0,0 +1,129 @@
+// 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_TWO_STAGE_STATUS_TRACKER_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_TWO_STAGE_STATUS_TRACKER_H_
+
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "constants/status_codes.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "utils/status_tracker.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+
+// A StatusTracker for recording validation status codes and failure codes to
+// another StatusTracker.
+//
+// This implementation is intended to be used when there are outside conditions
+// that determine if the status codes should be written to the contained
+// StatusTracker.
+//
+// There are two holding stages for status codes. The three Record* methods will
+// store the codes into stage 1. These status codes can either be written to the
+// underlying StatusTracker by calling WriteStage1() or they can be moved to
+// stage 2 by calling MoveStage1ToStage2().
+//
+// Once in stage 2, the codes can be written to the underlying StatusTracker by
+// calling WriteStage2().
+class TwoStageStatusTracker : public StatusTracker {
+ public:
+  explicit TwoStageStatusTracker(StatusTracker& hash_tracker)
+      : hash_tracker_(hash_tracker) {}
+
+  void RecordSuccess(SuccessStatusCode code,
+                     ValidationTracker::RecordOptions options) override;
+
+  void RecordFailure(FailureStatusCode code,
+                     ValidationTracker::RecordOptions options) override;
+
+  void RecordInformational(InformationalStatusCode code,
+                           ValidationTracker::RecordOptions options) override;
+
+  // Writes all the status codes in stage 1 to the underlying StatusTracker.
+  // Afterwards stage 1 will be cleared.
+  void WriteStage1();
+
+  // Writes all the status codes in stage 2 to the underlying StatusTracker.
+  // Afterwards stage 2 will be cleared.
+  void WriteStage2();
+
+  // Clears all the status codes in stage 1.
+  void ClearStage1() {
+    stage_1_success_.clear();
+    stage_1_failure_.clear();
+    stage_1_informational_.clear();
+  }
+
+  // Clears all the status codes in stage 2.
+  void ClearStage2() {
+    stage_2_success_.clear();
+    stage_2_failure_.clear();
+    stage_2_informational_.clear();
+  }
+
+  // Moves all the status codes in stage 1 to stage 2. Afterwards stage 1 will
+  // be cleared.
+  void MoveStage1ToStage2() {
+    stage_2_success_.insert(stage_2_success_.end(), stage_1_success_.begin(),
+                            stage_1_success_.end());
+    stage_2_failure_.insert(stage_2_failure_.end(), stage_1_failure_.begin(),
+                            stage_1_failure_.end());
+    stage_2_informational_.insert(stage_2_informational_.end(),
+                                  stage_1_informational_.begin(),
+                                  stage_1_informational_.end());
+    ClearStage1();
+  }
+
+  bool HasFailuresInStage1() const { return !stage_1_failure_.empty(); }
+
+ private:
+  // A saved copy of ValidationTracker::RecordOptions (using std::string members
+  // instead of absl::string_view).
+  struct SavedRecordOptions {
+    explicit SavedRecordOptions(ValidationTracker::RecordOptions options)
+        : url(options.url), explanation(options.explanation) {}
+
+    ValidationTracker::RecordOptions Get() const {
+      return ValidationTracker::RecordOptions{
+          .url = url,
+          .explanation = explanation,
+      };
+    }
+    std::string url;
+    std::string explanation;
+  };
+  template <typename T>
+  using TrackerRecord = std::pair<T, SavedRecordOptions>;
+
+  StatusTracker& hash_tracker_;
+
+  std::vector<TrackerRecord<SuccessStatusCode>> stage_1_success_;
+  std::vector<TrackerRecord<FailureStatusCode>> stage_1_failure_;
+  std::vector<TrackerRecord<InformationalStatusCode>> stage_1_informational_;
+
+  std::vector<TrackerRecord<SuccessStatusCode>> stage_2_success_;
+  std::vector<TrackerRecord<FailureStatusCode>> stage_2_failure_;
+  std::vector<TrackerRecord<InformationalStatusCode>> stage_2_informational_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_TWO_STAGE_STATUS_TRACKER_H_
diff --git a/utils/two_stage_status_tracker_test.cc b/utils/two_stage_status_tracker_test.cc
new file mode 100644
index 0000000..86427b9
--- /dev/null
+++ b/utils/two_stage_status_tracker_test.cc
@@ -0,0 +1,280 @@
+// 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/two_stage_status_tracker.h"
+
+#include <string>
+
+#include "absl/strings/str_cat.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/assertion.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+#include "utils/status_tracker.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+
+class RecordingStatusTracker : public StatusTracker {
+ public:
+  explicit RecordingStatusTracker(ValidationTracker& tracker)
+      : tracker_(tracker) {}
+
+  void RecordSuccess(SuccessStatusCode code,
+                     ValidationTracker::RecordOptions options) override {
+    tracker_.RecordSuccess(code, options);
+  }
+  void RecordFailure(FailureStatusCode code,
+                     ValidationTracker::RecordOptions options) override {
+    tracker_.RecordFailure(code, options);
+  }
+  void RecordInformational(InformationalStatusCode code,
+                           ValidationTracker::RecordOptions options) override {
+    tracker_.RecordInformational(code, options);
+  }
+
+  ValidationTracker& tracker_;
+};
+
+TEST(TwoStageStatusTrackerTest, DoesNotWriteOnRecord) {
+  TestValidationTracker t;
+  RecordingStatusTracker tracker(t.tracker());
+  TwoStageStatusTracker pending_tracker(tracker);
+
+  pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {});
+  pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                                {});
+  pending_tracker.RecordInformational(
+      InformationalStatusCode::kIngredientUnknownProvenance, {});
+
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  EXPECT_THAT(t.GetInformationals(), IsEmpty());
+}
+
+TEST(TwoStageStatusTrackerTest, WritesStage1OnlyOnce) {
+  TestValidationTracker t;
+  RecordingStatusTracker tracker(t.tracker());
+  TwoStageStatusTracker pending_tracker(tracker);
+
+  pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {});
+  pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                                {});
+  pending_tracker.RecordInformational(
+      InformationalStatusCode::kIngredientUnknownProvenance, {});
+
+  pending_tracker.WriteStage1();
+  EXPECT_THAT(
+      t.GetSuccesses(),
+      ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch)));
+  EXPECT_THAT(
+      t.GetFailures(),
+      ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch)));
+  EXPECT_THAT(t.GetInformationals(),
+              ElementsAre(absl::StrCat(
+                  InformationalStatusCode::kIngredientUnknownProvenance)));
+
+  pending_tracker.WriteStage1();
+  EXPECT_THAT(
+      t.GetSuccesses(),
+      ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch)));
+  EXPECT_THAT(
+      t.GetFailures(),
+      ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch)));
+  EXPECT_THAT(t.GetInformationals(),
+              ElementsAre(absl::StrCat(
+                  InformationalStatusCode::kIngredientUnknownProvenance)));
+}
+
+TEST(TwoStageStatusTrackerTest, ClearsStage1AfterMovingToStage2) {
+  TestValidationTracker t;
+  RecordingStatusTracker tracker(t.tracker());
+  TwoStageStatusTracker pending_tracker(tracker);
+
+  pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {});
+  pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                                {});
+  pending_tracker.RecordInformational(
+      InformationalStatusCode::kIngredientUnknownProvenance, {});
+
+  pending_tracker.MoveStage1ToStage2();
+
+  pending_tracker.WriteStage1();
+
+  EXPECT_THAT(t.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  EXPECT_THAT(t.GetInformationals(), IsEmpty());
+}
+
+TEST(TwoStageStatusTrackerTest,
+     CallingMoveStage1ToStage2TwiceDoesNotDuplicateEntries) {
+  TestValidationTracker t;
+  RecordingStatusTracker tracker(t.tracker());
+  TwoStageStatusTracker pending_tracker(tracker);
+
+  pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {});
+  pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                                {});
+  pending_tracker.RecordInformational(
+      InformationalStatusCode::kIngredientUnknownProvenance, {});
+
+  pending_tracker.MoveStage1ToStage2();
+  pending_tracker.MoveStage1ToStage2();
+
+  pending_tracker.WriteStage2();
+
+  EXPECT_THAT(
+      t.GetSuccesses(),
+      ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch)));
+  EXPECT_THAT(
+      t.GetFailures(),
+      ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch)));
+  EXPECT_THAT(t.GetInformationals(),
+              ElementsAre(absl::StrCat(
+                  InformationalStatusCode::kIngredientUnknownProvenance)));
+}
+
+TEST(TwoStageStatusTrackerTest, WritesStage2OnlyOnce) {
+  TestValidationTracker t;
+  RecordingStatusTracker tracker(t.tracker());
+  TwoStageStatusTracker pending_tracker(tracker);
+
+  pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch, {});
+  pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                                {});
+  pending_tracker.RecordInformational(
+      InformationalStatusCode::kIngredientUnknownProvenance, {});
+
+  pending_tracker.MoveStage1ToStage2();
+
+  pending_tracker.WriteStage2();
+  EXPECT_THAT(
+      t.GetSuccesses(),
+      ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch)));
+  EXPECT_THAT(
+      t.GetFailures(),
+      ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch)));
+  EXPECT_THAT(t.GetInformationals(),
+              ElementsAre(absl::StrCat(
+                  InformationalStatusCode::kIngredientUnknownProvenance)));
+
+  pending_tracker.WriteStage2();
+  EXPECT_THAT(
+      t.GetSuccesses(),
+      ElementsAre(absl::StrCat(SuccessStatusCode::kAssertionBmffHashMatch)));
+  EXPECT_THAT(
+      t.GetFailures(),
+      ElementsAre(absl::StrCat(FailureStatusCode::kAssertionBmffHashMismatch)));
+  EXPECT_THAT(t.GetInformationals(),
+              ElementsAre(absl::StrCat(
+                  InformationalStatusCode::kIngredientUnknownProvenance)));
+}
+
+TEST(TwoStageStatusTrackerTest, SavesStringOptionsForWriteStage1) {
+  TestValidationTracker t;
+  RecordingStatusTracker tracker(t.tracker());
+  TwoStageStatusTracker pending_tracker(tracker);
+
+  {
+    std::string url = "self#jumbf=/c2pa/blah";
+    std::string explanation = "some explanation";
+    pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch,
+                                  {.url = url, .explanation = explanation});
+    pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                                  {.url = url, .explanation = explanation});
+    pending_tracker.RecordInformational(
+        InformationalStatusCode::kIngredientUnknownProvenance,
+        {.url = url, .explanation = explanation});
+  }
+  pending_tracker.WriteStage1();
+  EXPECT_THAT(
+      t.GetSuccessStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.bmffHash.match"
+        url: "self#jumbf=/c2pa/blah"
+        explanation: "some explanation"
+      )pb"))));
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.bmffHash.mismatch"
+        url: "self#jumbf=/c2pa/blah"
+        explanation: "some explanation"
+      )pb"))));
+  EXPECT_THAT(
+      t.GetInformationalStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "ingredient.unknownProvenance"
+        url: "self#jumbf=/c2pa/blah"
+        explanation: "some explanation"
+      )pb"))));
+}
+
+TEST(TwoStageStatusTrackerTest, SavesStringOptionsForWriteStage2) {
+  TestValidationTracker t;
+  RecordingStatusTracker tracker(t.tracker());
+  TwoStageStatusTracker pending_tracker(tracker);
+
+  {
+    std::string url = "self#jumbf=/c2pa/blah";
+    std::string explanation = "some explanation";
+    pending_tracker.RecordSuccess(SuccessStatusCode::kAssertionBmffHashMatch,
+                                  {.url = url, .explanation = explanation});
+    pending_tracker.RecordFailure(FailureStatusCode::kAssertionBmffHashMismatch,
+                                  {.url = url, .explanation = explanation});
+    pending_tracker.RecordInformational(
+        InformationalStatusCode::kIngredientUnknownProvenance,
+        {.url = url, .explanation = explanation});
+  }
+  pending_tracker.MoveStage1ToStage2();
+
+  pending_tracker.WriteStage2();
+  EXPECT_THAT(
+      t.GetSuccessStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.bmffHash.match"
+        url: "self#jumbf=/c2pa/blah"
+        explanation: "some explanation"
+      )pb"))));
+  EXPECT_THAT(
+      t.GetFailureStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.bmffHash.mismatch"
+        url: "self#jumbf=/c2pa/blah"
+        explanation: "some explanation"
+      )pb"))));
+  EXPECT_THAT(
+      t.GetInformationalStatuses(),
+      ElementsAre(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "ingredient.unknownProvenance"
+        url: "self#jumbf=/c2pa/blah"
+        explanation: "some explanation"
+      )pb"))));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/utils/uri.cc b/utils/uri.cc
new file mode 100644
index 0000000..566c758
--- /dev/null
+++ b/utils/uri.cc
@@ -0,0 +1,43 @@
+// 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/uri.h"
+
+#include <cstddef>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/strip.h"
+#include "constants/labels.h"
+
+namespace credentio {
+
+absl::StatusOr<absl::string_view> GetManifestLabelFromAbsoluteUri(
+    absl::string_view uri) {
+  if (!absl::ConsumePrefix(&uri, "self#jumbf=/c2pa/")) {
+    return absl::InvalidArgumentError(absl::StrCat("invalid URI: ", uri));
+  }
+  size_t pos = uri.find(kManifestLabelDelimiter);
+  absl::string_view label = uri.substr(0, pos);
+  if (label.empty()) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("empty manifest label in URI: ", uri));
+  }
+  return label;
+}
+
+}  // namespace credentio
diff --git a/utils/uri.h b/utils/uri.h
new file mode 100644
index 0000000..09f145e
--- /dev/null
+++ b/utils/uri.h
@@ -0,0 +1,35 @@
+// 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_URI_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_URI_H_
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Extracts the C2PA manifest label from the given absolute URI.
+//
+// The URI must be a JUMBF URI that starts with `self#jumbf=/c2pa/`.
+//
+// Returns an error if the URI is not a valid JUMBF URI or does not
+// start with `self#jumbf=/c2pa/`.
+absl::StatusOr<absl::string_view> GetManifestLabelFromAbsoluteUri(
+    absl::string_view uri);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_URI_H_
diff --git a/utils/uri_test.cc b/utils/uri_test.cc
new file mode 100644
index 0000000..d135f46
--- /dev/null
+++ b/utils/uri_test.cc
@@ -0,0 +1,49 @@
+// 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/uri.h"
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio::GetManifestLabelFromAbsoluteUri;
+
+TEST(GetManifestLabelFromAbsoluteUriTest, Success) {
+  EXPECT_THAT(GetManifestLabelFromAbsoluteUri(
+                  "self#jumbf=/c2pa/foo.bar/c2pa.assertions/blah"),
+              IsOkAndHolds("foo.bar"));
+  EXPECT_THAT(GetManifestLabelFromAbsoluteUri("self#jumbf=/c2pa/foo.bar"),
+              IsOkAndHolds("foo.bar"));
+}
+
+TEST(GetManifestLabelFromUriTest, Failure) {
+  EXPECT_THAT(GetManifestLabelFromAbsoluteUri(""),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(GetManifestLabelFromAbsoluteUri("/c2pa/foo.bar"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(GetManifestLabelFromAbsoluteUri("self#jumbf=/c2pa/"),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+  EXPECT_THAT(
+      GetManifestLabelFromAbsoluteUri("self#jumbf=/c2pa//c2pa.assertions/"),
+      StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+}  // namespace
diff --git a/uuid/BUILD b/uuid/BUILD
new file mode 100644
index 0000000..70b5149
--- /dev/null
+++ b/uuid/BUILD
@@ -0,0 +1,55 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "uuid",
+    srcs = ["uuid.cc"],
+    hdrs = ["uuid.h"],
+    deps = [
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/numeric:int128",
+        "@abseil-cpp//absl/random",
+        "@abseil-cpp//absl/random:distributions",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/endian:endian_reading",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "uuid_test",
+    srcs = ["uuid_test.cc"],
+    deps = [
+        ":uuid",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/numeric:int128",
+        "@abseil-cpp//absl/random",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/uuid/uuid.cc b/uuid/uuid.cc
new file mode 100644
index 0000000..4235c8d
--- /dev/null
+++ b/uuid/uuid.cc
@@ -0,0 +1,167 @@
+// 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 "uuid/uuid.h"
+
+#include <net/if.h>
+#include <netinet/in.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <cstdint>
+#include <string>
+
+#include "absl/base/no_destructor.h"
+#include "absl/log/check.h"
+#include "absl/log/log.h"
+#include "absl/random/distributions.h"
+#include "absl/random/random.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/endian/endian_reading.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+
+namespace {
+class UuidGeneratorImpl : public UuidGenerator {
+ public:
+  // Generates a V4 UUID as documented in
+  // https://tools.ietf.org/html/rfc4122#section-4.4
+  Uuid Generate() const override {
+    absl::BitGen gen;
+    const auto lo = absl::Uniform<uint64_t>(gen);
+    const auto hi = absl::Uniform<uint64_t>(gen);
+    return Uuid((lo & 0x0fffffffffffffffUL) | 0x4000000000000000UL,
+                (hi & 0xffffffffffffbfffUL) | 0x0000000000008000UL);
+  };
+};
+}  // namespace
+
+////////////////////////////////////////////////////////////////////////
+// Uuid and related helpers
+
+// Canonical Uids.
+const Uuid Uuid::kInvalid;
+
+// We reserve the node 00:00:00:00:00:00, time [0, 999] for invalid
+// Uuids (constants)
+const Uuid Uuid::kValidMin(0, ValidMinLo());
+const Uuid Uuid::kValidMax(~0ULL, ~0ULL);
+
+std::string Uuid::ToString() const {
+  if (*this == kInvalid) return std::string(kInvalidRepr);
+
+  auto to_hex = [](uint64_t v, int num_chars, char* out) {
+    static constexpr char hex_char[] = {'0', '1', '2', '3', '4', '5', '6', '7',
+                                        '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
+    for (int i = num_chars - 1; i >= 0; --i) {
+      *out++ = hex_char[(v >> (i * 4)) & 0xf];
+    }
+  };
+  uint32_t time_low = low64() & 0xffffffff;
+  uint32_t time_mid = (low64() >> 32) & 0xffff;
+  uint32_t time_high = (low64() >> 48) & 0xffff;
+  uint32_t sequence = high64() & 0xffff;
+  uint64_t node = (high64() >> 16) & 0xffffffffffffULL;
+  char buf[36];
+  char* ptr = buf;
+  to_hex(time_low, 8, ptr);
+  ptr += 8;
+  *ptr++ = '-';
+  to_hex(time_mid, 4, ptr);
+  ptr += 4;
+  *ptr++ = '-';
+  to_hex(time_high, 4, ptr);
+  ptr += 4;
+  *ptr++ = '-';
+  to_hex(sequence, 4, ptr);
+  ptr += 4;
+  *ptr++ = '-';
+  to_hex(node, 12, ptr);
+  return std::string(buf, 36);
+}
+
+absl::StatusOr<Uuid> Uuid::FromString(absl::string_view s) {
+  Uuid uuid;
+  if (!ParseFromString(s, &uuid)) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Syntax error: string '", s, "' is not a Uuid"));
+  }
+  return uuid;
+}
+
+std::string Uuid::ToProtoBytes() const {
+  std::string s;
+  ToProtoBytes(&s);
+  return s;
+}
+
+// The UUid byte format is a mixed-endian format specified by RFC 4122.
+void Uuid::ToProtoBytes(std::string* s) const {
+  // Special case for kInvalidUID, which is represented as the empty string.
+  if (*this == kInvalid) {
+    s->assign("");
+    return;
+  }
+
+  char bytes[16];
+  uint32_t time_low = low64() & 0xffffffff;
+  uint16_t time_mid = (low64() >> 32) & 0xffff;
+  uint16_t time_hi_and_version = (low64() >> 48) & 0xffff;
+  uint16_t clock_seq = high64() & 0xffff;
+  uint16_t node_low = (high64() >> 16) & 0xffff;
+  uint32_t node_hi = (high64() >> 32) & 0xffffffff;
+
+  riegeli::WriteBigEndian<uint32_t>(time_low, &bytes[0]);
+  riegeli::WriteBigEndian<uint16_t>(time_mid, &bytes[4]);
+  riegeli::WriteBigEndian<uint16_t>(time_hi_and_version, &bytes[6]);
+  riegeli::WriteBigEndian<uint16_t>(clock_seq, &bytes[8]);
+  riegeli::WriteBigEndian<uint32_t>(node_hi, &bytes[10]);
+  riegeli::WriteBigEndian<uint16_t>(node_low, &bytes[14]);
+  s->assign(bytes, 16);
+}
+
+absl::StatusOr<Uuid> Uuid::FromProtoBytes(absl::string_view bytes) {
+  if (bytes.empty()) {
+    return Uuid(kInvalid);
+  } else if (bytes.size() == 16) {
+    const char* data = bytes.data();
+    uint64_t time_low = riegeli::ReadBigEndian<uint32_t>(&data[0]);
+    uint64_t time_mid = riegeli::ReadBigEndian<uint16_t>(&data[4]);
+    uint64_t time_hi_and_version = riegeli::ReadBigEndian<uint16_t>(&data[6]);
+    uint64_t clock_seq = riegeli::ReadBigEndian<uint16_t>(&data[8]);
+    uint64_t node_hi = riegeli::ReadBigEndian<uint32_t>(&data[10]);
+    uint64_t node_low = riegeli::ReadBigEndian<uint16_t>(&data[14]);
+    uint64_t low64 = (time_hi_and_version << 48) | (time_mid << 32) | time_low;
+    uint64_t high64 = (node_hi << 32) | (node_low << 16) | clock_seq;
+    return Uuid(high64, low64);
+  }
+  return ::absl::InvalidArgumentError("Syntax error: bytes are not a Uuid");
+}
+
+bool Uuid::IsValid() const { return kValidMin <= *this && *this <= kValidMax; }
+
+bool Uuid::IsMagic() const { return high_ == 0 && low_ < ValidMinLo(); }
+
+const UuidGenerator& UuidGenerator::Default() {
+  static const absl::NoDestructor<UuidGeneratorImpl> kDefaultUuidGenerator;
+  return *kDefaultUuidGenerator;
+}
+
+}  // namespace credentio
diff --git a/uuid/uuid.h b/uuid/uuid.h
new file mode 100644
index 0000000..0561b69
--- /dev/null
+++ b/uuid/uuid.h
@@ -0,0 +1,221 @@
+// 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.
+//
+
+// A Uuid is an identifier, that is unique over all space and time.  The default
+// implementation is based on UUID (DCE version), which uses the Ethernet MAC
+// address of the machine, and a timestamp (which we replace with a sequence
+// number).
+//
+//   http://en.wikipedia.org/wiki/Universally_unique_identifier
+//   http://tools.ietf.org/html/rfc4122.html
+//
+// The reason to use DCE is that it is based on the physical
+// address, which may aid debugging.
+
+#ifndef THIRD_PARTY_CREDENTIO_UUID_UUID_H_
+#define THIRD_PARTY_CREDENTIO_UUID_UUID_H_
+
+#include <cstdint>
+#include <ostream>
+#include <string>
+
+#include "absl/log/check.h"
+#include "absl/numeric/int128.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Uuid is the type of unique identifiers.
+class Uuid {
+ public:
+  // In addition to the following constructors, the default copy constructor and
+  // assignment operator are also allowed.
+  // Uuids are not POD, because the default constructor initializes them to
+  // kInvalid. However, the Google Style Guide allows static Uuid constants
+  // because Uuid constructors are all constexpr, and the type has no
+  // destructors.
+  constexpr Uuid() : high_(0), low_(InvalidMinLo()) {}  // equal to kInvalid
+  explicit constexpr Uuid(uint64_t low) : high_(0), low_(low) {}
+  constexpr Uuid(uint64_t high, uint64_t low) : high_(high), low_(low) {}
+  constexpr explicit Uuid(absl::uint128 raw)
+      : Uuid(absl::Uint128High64(raw), absl::Uint128Low64(raw)) {}
+
+  bool IsValid() const;
+
+  // Conversion to/from the ASCII form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. The
+  // format uses the following ABNF, where each numerical component is
+  // represented in hex, with leading zeros (although the FromString variants
+  // will accept input without leading zeros). The 'x's above show the maximum
+  // length of each of those components.
+  //
+  //   UUID = time-low "-" time-mid "-" time-high-and-version "-"
+  //   clock-seq-and-reserved clock-seq-low "-" node
+  //
+  // FromString returns INVALID_ARGUMENT, and FromStringOrDie CHECK-fails if
+  // there is a syntax error.
+  std::string ToString() const;
+  static absl::StatusOr<Uuid> FromString(absl::string_view s);
+  static constexpr Uuid FromStringOrDie(absl::string_view s) {
+    Uuid uuid;
+    CHECK(Uuid::ParseFromString(s, &uuid))
+        << "Syntax error: string '" << s << "' is not a Uuid";
+    return uuid;
+  }
+
+  absl::uint128 ToRawNumber() const { return absl::MakeUint128(high_, low_); }
+  uint64_t high64() const { return high_; }
+  uint64_t low64() const { return low_; }
+
+  // Conversion to/from 16-byte binary strings.  Layout and byte order
+  // are specified by RFC 4122.
+  std::string ToProtoBytes() const;
+  void ToProtoBytes(std::string* s) const;
+
+  // Convert proto string to Uuid. Returns INVALID_ARGUMENT if the Uuid could
+  // not be parsed correctly.
+  static absl::StatusOr<Uuid> FromProtoBytes(absl::string_view bytes);
+
+  // Canonical Uuids
+  static const Uuid kInvalid;  // Represents errors.
+  static constexpr absl::string_view kInvalidRepr =
+      "Uid::kInvalid";  // String representation of kInvalid.
+
+  // We reserve 999 in invalid Uuids (in addition to kInvalid) that can be
+  // used as special sentinel values that will be different from any id returned
+  // by a Uuid generator. Magic values can be declared as static compile-time
+  // constants.
+  template <uint64_t k>
+  static constexpr Uuid Magic() {
+    static_assert(k < ValidMinLo(), "Argument too large");
+    static_assert(k > InvalidMinLo(), "Argument too small");
+    return Uuid(k);
+  }
+  bool IsMagic() const;
+
+  // Range of valid Uuids
+  static const Uuid kValidMin;
+  static const Uuid kValidMax;
+
+  template <typename H>
+  friend H AbslHashValue(H h, const Uuid& uuid) {
+    return H::combine(std::move(h), uuid.high_, uuid.low_);
+  }
+
+ private:
+  // Underlying parsing function for ASCII form conversions.
+  static constexpr bool ParseFromString(absl::string_view s, Uuid* uuid) {
+    auto consume_hex = [](int max_chars, absl::string_view* input,
+                          uint64_t* res) {
+      *res = 0;
+      int count = 0;
+      while (count < max_chars && !input->empty()) {
+        char c = input->front();
+        uint32_t v = 0;
+        if (c >= '0' && c <= '9') {
+          v = c - '0';
+        } else if (c >= 'a' && c <= 'f') {
+          v = c - 'a' + 10;
+        } else if (c >= 'A' && c <= 'F') {
+          v = c - 'A' + 10;
+        } else {
+          break;
+        }
+        *res = (*res << 4) + v;
+        input->remove_prefix(1);
+        ++count;
+      }
+      return count > 0;
+    };
+    auto consume_char = [](char check_char, absl::string_view* input) {
+      if (input->empty() || input->front() != check_char) return false;
+      input->remove_prefix(1);
+      return true;
+    };
+
+    if (s == kInvalidRepr) {
+      // Equal to kInvalid (canonical value cannot be used here due to constexpr
+      // restrictions).
+      *uuid = Uuid();
+      return true;
+    }
+
+    absl::string_view input(s);
+    uint64_t time_low = 0, time_mid = 0, time_high = 0, sequence = 0, node = 0;
+    bool valid = true;
+    valid &= consume_hex(8, &input, &time_low);
+    valid &= consume_char('-', &input);
+    valid &= consume_hex(4, &input, &time_mid);
+    valid &= consume_char('-', &input);
+    valid &= consume_hex(4, &input, &time_high);
+    valid &= consume_char('-', &input);
+    valid &= consume_hex(4, &input, &sequence);
+    valid &= consume_char('-', &input);
+    valid &= consume_hex(12, &input, &node);
+    valid &= input.empty();
+
+    if (!valid) {
+      return false;
+    }
+
+    const uint64_t high64 =
+        ((node & 0xffffffffffffULL) << 16) | (sequence & 0xffffULL);
+    const uint64_t low64 = ((time_high & 0xffffULL) << 48) |
+                           ((time_mid & 0xffffULL) << 32) |
+                           (time_low & 0xffffffffULL);
+    *uuid = Uuid(high64, low64);
+    return true;
+  }
+
+  // We reserve the node 00:00:00:00:00:00, time [0, 999] for invalid
+  // Uuids (constants)
+  static constexpr uint64_t ValidMinLo() { return 1000; }
+  static constexpr uint64_t InvalidMinLo() { return 0; }
+
+  uint64_t high_{0};
+  uint64_t low_{0};
+};
+
+// Comparison operators.
+inline bool operator==(Uuid a, Uuid b) {
+  return a.high64() == b.high64() && a.low64() == b.low64();
+}
+inline bool operator!=(Uuid a, Uuid b) { return !(a == b); }
+inline bool operator<(Uuid a, Uuid b) {
+  return a.high64() < b.high64() ||
+         (a.high64() == b.high64() && a.low64() < b.low64());
+}
+inline bool operator<=(Uuid a, Uuid b) {
+  return a.high64() < b.high64() ||
+         (a.high64() == b.high64() && a.low64() <= b.low64());
+}
+
+// Formatting.
+inline std::ostream& operator<<(std::ostream& o, Uuid id) {
+  return o << id.ToString();
+}
+
+class UuidGenerator {
+ public:
+  virtual ~UuidGenerator() = default;
+  virtual Uuid Generate() const = 0;
+  // Generates a V4 UUID as documented in
+  // https://tools.ietf.org/html/rfc4122#section-4.4
+  static const UuidGenerator& Default();
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UUID_UUID_H_
diff --git a/uuid/uuid_test.cc b/uuid/uuid_test.cc
new file mode 100644
index 0000000..935b705
--- /dev/null
+++ b/uuid/uuid_test.cc
@@ -0,0 +1,319 @@
+// 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 "uuid/uuid.h"
+
+#include <cstdint>
+#include <string>
+#include <type_traits>
+
+#include "absl/log/check.h"
+#include "absl/numeric/int128.h"
+#include "absl/random/random.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+using ::absl::StatusCode::kInvalidArgument;
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(UuidTest, IsTriviallyDestructible) {
+  // The type is not POD because it has a default constructor that initializes
+  // the value to kInvalid. Without such as constructor, Uuids declared on the
+  // stack would have an indeterminate value. However, the type is trivially
+  // destructible, which means the Google Style Guide allows static constants
+  // of type Uuid.
+  static_assert(std::is_trivially_destructible_v<Uuid>,
+                "Uuid is not trivially destructible");
+}
+
+TEST(UuidTest, ConstexprFromStringOrDie) {
+  constexpr auto kUuid =
+      Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a");
+  EXPECT_FALSE(kUuid.IsMagic());
+}
+
+TEST(UuidTest, CompareData) {
+  const Uuid id0_0(0, 0);
+  const Uuid id0_1(0, 1);
+  const Uuid id1_0(1, 0);
+
+  EXPECT_TRUE(id0_0 == id0_0);
+  EXPECT_FALSE(id0_0 != id0_0);
+  EXPECT_FALSE(id0_0 < id0_0);
+  EXPECT_TRUE(id0_0 <= id0_0);
+
+  EXPECT_FALSE(id0_0 == id0_1);
+  EXPECT_TRUE(id0_0 != id0_1);
+  EXPECT_TRUE(id0_0 < id0_1);
+  EXPECT_TRUE(id0_0 <= id0_1);
+
+  EXPECT_FALSE(id0_0 == id1_0);
+  EXPECT_TRUE(id0_0 != id1_0);
+  EXPECT_TRUE(id0_0 < id1_0);
+  EXPECT_TRUE(id0_0 <= id1_0);
+
+  EXPECT_FALSE(id0_1 == id0_0);
+  EXPECT_TRUE(id0_1 != id0_0);
+  EXPECT_FALSE(id0_1 < id0_0);
+  EXPECT_FALSE(id0_1 <= id0_0);
+
+  EXPECT_FALSE(id1_0 == id0_0);
+  EXPECT_TRUE(id1_0 != id0_0);
+  EXPECT_FALSE(id1_0 < id0_0);
+  EXPECT_FALSE(id1_0 <= id0_0);
+}
+
+TEST(UuidTest, Invalid) {
+  Uuid id;
+  EXPECT_EQ(Uuid::kInvalid, id);
+}
+
+TEST(UuidTest, IsValid) {
+  EXPECT_FALSE(Uuid(0).IsValid());
+  EXPECT_FALSE(Uuid(1).IsValid());
+
+  absl::uint128 i = Uuid(Uuid::kValidMin).ToRawNumber();
+  i--;
+  EXPECT_FALSE(Uuid(i).IsValid());
+
+  i++;
+  EXPECT_TRUE(Uuid(i).IsValid());
+  EXPECT_TRUE(Uuid(Uuid::kValidMax).IsValid());
+}
+
+TEST(UuidTest, Magic) {
+  // This test will not compile unless 0 < key < 1000.
+  static constexpr uint64_t key = 27;
+
+  // Four different ways of specifying the same static constant.
+  static const Uuid m1 = Uuid::Magic<key>();
+  static const Uuid m2 = Uuid(key);
+  static const Uuid m3 = Uuid(0, key);
+  static const Uuid m4 = Uuid(absl::uint128(key));
+
+  EXPECT_TRUE(m1 == m2);
+  EXPECT_TRUE(m2 == m3);
+  EXPECT_TRUE(m3 == m4);
+}
+
+TEST(UuidTest, IsMagic) {
+  // This test will not compile unless 0 < magic_key < 1000.
+  static constexpr uint64_t magic_key = 27;
+
+  // Four different ways of specifying the same static constant.
+  EXPECT_TRUE(Uuid::Magic<magic_key>().IsMagic());
+  EXPECT_TRUE(Uuid(magic_key).IsMagic());
+  EXPECT_TRUE(Uuid(0, magic_key).IsMagic());
+  EXPECT_TRUE(Uuid(absl::uint128(magic_key)).IsMagic());
+
+  EXPECT_FALSE(Uuid(1001).IsMagic());
+  EXPECT_FALSE(Uuid(0, 1001).IsMagic());
+  EXPECT_FALSE(Uuid(absl::uint128(1001)).IsMagic());
+}
+
+TEST(UuidTest, Number) {
+  absl::uint128 i1 = absl::MakeUint128(0, 1);
+  Uuid id1(1);
+  EXPECT_EQ(i1, id1.ToRawNumber());
+  EXPECT_EQ(0, id1.high64());
+  EXPECT_EQ(1, id1.low64());
+
+  absl::uint128 i2 = absl::MakeUint128(0, 1275);
+  Uuid id2(0, 1275);
+  EXPECT_EQ(i2, id2.ToRawNumber());
+  EXPECT_EQ(0, id2.high64());
+  EXPECT_EQ(1275, id2.low64());
+
+  absl::uint128 i3 = absl::MakeUint128(1, 0);
+  Uuid id3(1, 0);
+  EXPECT_EQ(i3, id3.ToRawNumber());
+  EXPECT_EQ(1, id3.high64());
+  EXPECT_EQ(0, id3.low64());
+
+  absl::uint128 i4 = absl::MakeUint128(2, 14);
+  Uuid id4(2, 14);
+  EXPECT_EQ(i4, id4.ToRawNumber());
+  EXPECT_EQ(2, id4.high64());
+  EXPECT_EQ(14, id4.low64());
+
+  absl::BitGen rng;
+  const auto rand64 = [&rng]() { return absl::Uniform<uint64_t>(rng); };
+
+  for (int i = 0; i != 100; i++) {
+    uint64_t high = rand64();
+    uint64_t low = rand64();
+    absl::uint128 j = absl::MakeUint128(high, low);
+    Uuid id(high, low);
+    EXPECT_EQ(j, id.ToRawNumber());
+    EXPECT_EQ(high, id.high64());
+    EXPECT_EQ(low, id.low64());
+  }
+}
+
+TEST(UuidTest, Compare) {
+  EXPECT_TRUE(Uuid(1) == Uuid(1));
+  EXPECT_FALSE(Uuid(1) == Uuid(2));
+  EXPECT_FALSE(Uuid(1) != Uuid(1));
+  EXPECT_TRUE(Uuid(1) != Uuid(2));
+  EXPECT_TRUE(Uuid(1) < Uuid(2));
+  EXPECT_FALSE(Uuid(2) < Uuid(1));
+
+  absl::BitGen rng;
+  const auto rand64 = [&rng]() { return absl::Uniform<uint64_t>(rng); };
+
+  for (int i = 0; i != 100; i++) {
+    absl::uint128 i1 = absl::MakeUint128(rand64(), rand64());
+    absl::uint128 i2 = absl::MakeUint128(rand64(), rand64());
+    Uuid id1(i1);
+    Uuid id2(i2);
+    const Uuid sid1(absl::Uint128High64(i1), absl::Uint128Low64(i1));
+    const Uuid sid2(absl::Uint128High64(i2), absl::Uint128Low64(i2));
+    EXPECT_EQ(id1, id1);
+    EXPECT_EQ(id1, sid1);
+    EXPECT_EQ(sid1, id1);
+    if (i1 < i2) {
+      EXPECT_TRUE(id1 < id2);
+      EXPECT_FALSE(id2 < id1);
+      EXPECT_FALSE(id1 == id2);
+
+      EXPECT_TRUE(id1 < sid2);
+      EXPECT_FALSE(id2 < sid1);
+      EXPECT_FALSE(id1 == sid2);
+
+      EXPECT_TRUE(sid1 < id2);
+      EXPECT_FALSE(sid2 < id1);
+      EXPECT_FALSE(sid1 == id2);
+    } else if (i2 < i1) {
+      EXPECT_TRUE(id2 < id1);
+      EXPECT_FALSE(id1 < id2);
+      EXPECT_FALSE(id1 == id2);
+
+      EXPECT_TRUE(id2 < sid1);
+      EXPECT_FALSE(id1 < sid2);
+      EXPECT_FALSE(id1 == sid2);
+
+      EXPECT_TRUE(sid2 < id1);
+      EXPECT_FALSE(sid1 < id2);
+      EXPECT_FALSE(sid1 == id2);
+    } else {
+      EXPECT_EQ(id1, id2);
+      EXPECT_EQ(id1, sid2);
+      EXPECT_EQ(sid1, id2);
+    }
+  }
+}
+
+TEST(UuidTest, String) {
+  Uuid id(0x0f0e0d0c0b0a0908ULL, 0x0706050403020100ULL);
+  EXPECT_EQ("03020100-0504-0706-0908-0f0e0d0c0b0a", id.ToString());
+  EXPECT_EQ(id, Uuid::FromStringOrDie("3020100-504-706-908-f0e0d0c0b0a"));
+  EXPECT_EQ(id, Uuid::FromStringOrDie("3020100-504-706-908-F0E0D0C0B0A"));
+  EXPECT_EQ(id, Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a"));
+}
+
+TEST(UuidTest, StringSuccess) {
+  Uuid id(0x0f0e0d0c0b0a0908ULL, 0x0706050403020100ULL);
+  EXPECT_EQ("03020100-0504-0706-0908-0f0e0d0c0b0a", id.ToString());
+  EXPECT_THAT(Uuid::FromString("3020100-504-706-908-f0e0d0c0b0a"),
+              IsOkAndHolds(id));
+  EXPECT_THAT(Uuid::FromString("3020100-504-706-908-F0E0D0C0B0A"),
+              IsOkAndHolds(id));
+}
+
+TEST(UuidTest, StringFail) {
+  auto is_invalid = StatusIs(kInvalidArgument, HasSubstr("Syntax error"));
+  EXPECT_THAT(Uuid::FromString("I am not a Uuid"), is_invalid);
+  EXPECT_THAT(Uuid::FromString(""), is_invalid);
+  EXPECT_THAT(Uuid::FromString("00-00-00"), is_invalid);
+
+  // Individual components too long.
+  EXPECT_THAT(Uuid::FromString("111111118-1111-1111-1111-111111111111"),
+              is_invalid);
+  EXPECT_THAT(Uuid::FromString("11111111-11118-1111-1111-111111111111"),
+              is_invalid);
+  EXPECT_THAT(Uuid::FromString("11111111-1111-11118-1111-111111111111"),
+              is_invalid);
+  EXPECT_THAT(Uuid::FromString("11111111-1111-1111-11118-111111111111"),
+              is_invalid);
+  EXPECT_THAT(Uuid::FromString("11111111-1111-1111-1111-1111111111118"),
+              is_invalid);
+
+  // Missing components
+  EXPECT_THAT(Uuid::FromString("-1111-1111-1111-111111111111"), is_invalid);
+  EXPECT_THAT(Uuid::FromString("11111111-1111--1111-111111111111"), is_invalid);
+  EXPECT_THAT(Uuid::FromString("11111111-1111-1111-1111-"), is_invalid);
+  EXPECT_THAT(Uuid::FromString("----"), is_invalid);
+
+  // Extra component
+  EXPECT_THAT(Uuid::FromString("11111111-1111-1111-1111-111111111111-1"),
+              is_invalid);
+}
+
+TEST(UuidTest, StringDeath) {
+  EXPECT_DEATH(Uuid::FromStringOrDie("wrong"), "Syntax error");
+  EXPECT_DEATH(Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0Z"),
+               "Syntax error");
+  EXPECT_DEATH(Uuid::FromStringOrDie("Z3020100-0504-0706-0908-0f0e0d0c0b0a"),
+               "Syntax error");
+}
+
+TEST(UuidTest, Bytes) {
+  Uuid id(0x0f0e0d0c0b0a0908ULL, 0x0706050403020100ULL);
+  EXPECT_EQ("03020100-0504-0706-0908-0f0e0d0c0b0a", id.ToString());
+  std::string s;
+  s.assign("\x03\x02\x01\x00\x05\x04\x07\x06\x09\x08\x0f\x0e\x0d\x0c\x0b\x0a",
+           16);
+  EXPECT_EQ(s, id.ToProtoBytes());
+
+  auto id2 = Uuid::FromProtoBytes(s);
+  EXPECT_THAT(id2.status(), IsOk());
+  EXPECT_EQ(id, id2.value());
+}
+
+TEST(UuidTest, InvalidBytes) {
+  // Uuid::kInvalid is represented with the empty string.
+  Uuid id;
+  EXPECT_EQ(Uuid::kInvalid, id);
+  EXPECT_EQ("", id.ToProtoBytes());
+  auto id2 = Uuid::FromProtoBytes("");
+  EXPECT_THAT(id2.status(), IsOk());
+  EXPECT_EQ(Uuid::kInvalid, id2.value());
+}
+
+TEST(UuidTest, ParseError) {
+  auto is_invalid = StatusIs(kInvalidArgument, HasSubstr("Syntax error"));
+  std::string s;
+  s.resize(15);
+  EXPECT_THAT(Uuid::FromProtoBytes(s), is_invalid);
+
+  s.resize(17);
+  EXPECT_THAT(Uuid::FromProtoBytes(s), is_invalid);
+
+  s.resize(16);
+  EXPECT_THAT(Uuid::FromProtoBytes(s).status(), IsOk());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/validator/BUILD b/validator/BUILD
new file mode 100644
index 0000000..fcdc5cc
--- /dev/null
+++ b/validator/BUILD
@@ -0,0 +1,317 @@
+# 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.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "tracker",
+    srcs = ["tracker.cc"],
+    hdrs = ["tracker.h"],
+    deps = [
+        "//constants:labels",
+        "//constants:status_codes",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/strings",
+        "@protobuf//:protobuf_lite",
+    ],
+)
+
+cc_test(
+    name = "tracker_test",
+    srcs = ["tracker_test.cc"],
+    deps = [
+        ":tracker",
+        "//constants:status_codes",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "validator_metrics",
+    hdrs = ["validator_metrics.h"],
+    deps = [
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "@abseil-cpp//absl/strings",
+    ],
+)
+
+cc_library(
+    name = "result",
+    srcs = ["result.cc"],
+    hdrs = ["result.h"],
+    deps = [
+        "//proto:assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "result_test",
+    srcs = ["result_test.cc"],
+    deps = [
+        ":result",
+        "//proto:assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//testing:proto_test_utils",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "graph_internals",
+    srcs = ["graph_internals.cc"],
+    hdrs = ["graph_internals.h"],
+    deps = [
+        "//proto:assertion_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "graph_internals_test",
+    srcs = ["graph_internals_test.cc"],
+    deps = [
+        ":graph_internals",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "validation_result_internal",
+    srcs = ["validation_result_internal.cc"],
+    hdrs = ["validation_result_internal.h"],
+    deps = [
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "@abseil-cpp//absl/algorithm:container",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
+
+cc_library(
+    name = "validator_options",
+    hdrs = ["validator_options.h"],
+    deps = [
+        ":validator_metrics",
+        "//constants:spec_version",
+        "//crypto:crypto_read_handler",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/time:clock_interface",
+    ],
+)
+
+cc_test(
+    name = "validation_result_internal_test",
+    srcs = ["validation_result_internal_test.cc"],
+    deps = [
+        ":validation_result_internal",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "graph",
+    srcs = ["graph.cc"],
+    hdrs = ["graph.h"],
+    deps = [
+        ":graph_internals",
+        ":tracker",
+        ":validator_options",
+        "//assertion:hashed_uri_validator",
+        "//assertion:validator",
+        "//claim:validator",
+        "//constants:ingredient_relationships",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//crypto:hash",
+        "//jumbf:box",
+        "//jumbf:uri",
+        "//proto:assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:collection_data_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//uuid",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "graph_test",
+    srcs = ["graph_test.cc"],
+    deps = [
+        ":graph",
+        ":tracker",
+        ":validator_options",
+        "//assertion:validator",
+        "//constants:status_codes",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//jumbf:box",
+        "//jumbf:uri",
+        "//proto:assertion_cc_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//testing:fake_assertion_validator",
+        "//testing:fake_claim_validator",
+        "//testing:jumbf_utils",
+        "//testing:mock_assertion_validator",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/types:span",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "manifest_store_validator",
+    hdrs = ["manifest_store_validator.h"],
+    deps = [
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "manifest_store_validator_impl",
+    srcs = ["manifest_store_validator_impl.cc"],
+    hdrs = ["manifest_store_validator_impl.h"],
+    deps = [
+        ":graph",
+        ":manifest_store_validator",
+        ":result",
+        ":validator_metrics",
+        ":validator_options",
+        "//assertion:validator",
+        "//claim:validator",
+        "//constants:labels",
+        "//constants:status_codes",
+        "//cose:verifier",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//jumbf:box",
+        "//jumbf:parse",
+        "//jumbf:uri",
+        "//proto:assertion_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//utils:dual_status_tracker",
+        "//uuid",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "asset_validator",
+    hdrs = ["asset_validator.h"],
+    deps = [
+        ":result",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "asset_validator_impl",
+    srcs = ["asset_validator_impl.cc"],
+    hdrs = ["asset_validator_impl.h"],
+    deps = [
+        ":asset_validator",
+        ":manifest_store_validator",
+        ":manifest_store_validator_impl",
+        ":result",
+        ":validation_result_internal",
+        ":validator_metrics",
+        ":validator_options",
+        "//bindings:validator",
+        "//formats:core_registry",
+        "//formats:extractor",
+        "//formats:format",
+        "//formats:registry",
+        "//proto:validation_result_cc_proto",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
diff --git a/validator/asset_validator.h b/validator/asset_validator.h
new file mode 100644
index 0000000..0faeb98
--- /dev/null
+++ b/validator/asset_validator.h
@@ -0,0 +1,64 @@
+// 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_VALIDATOR_ASSET_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_H_
+
+#include <memory>
+#include <optional>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/reader.h"
+#include "validator/result.h"
+
+namespace credentio {
+
+// A validator of C2PA metadata embedded in media files (assets).
+class AssetValidator {
+ public:
+  virtual ~AssetValidator() = default;
+
+  // Extracts and verifies content credentials from an asset of a supported
+  // type. Returns an error if validation could not be performed (e.g., due to
+  // internal error) or no C2PA metadata is present.
+  //
+  // The `input` is a riegeli::Reader over the asset. It must support backwards
+  // seeks (e.g., riegeli::FileReader, riegeli::StringReader).
+  // The `media_type` is the IANA media type of the asset (e.g. "image/jpeg").
+  // If not provided, the media type will be determined from the input.
+  //
+  // Example:
+  // File input:
+  //   ABSL_ASSIGN_OR_RETURN(std::string media_type,
+  //                         credentio::MediaType(file_path));
+  //   riegeli::FileReader reader(file_path);
+  //   ABSL_RETURN_IF_ERROR(reader.status());
+  //   absl::StatusOr<std::unique_ptr<ValidationResult>> result =
+  //       validator->Validate(reader, *media_type);
+  //
+  // String input:
+  //   std::string contents = "contents";
+  //   riegeli::StringReader reader(contents);
+  //   absl::StatusOr<std::unique_ptr<ValidationResult>> result =
+  //       validator->Validate(reader, "image/jpeg");
+  virtual absl::StatusOr<std::unique_ptr<ValidationResult>> Validate(
+      riegeli::Reader& input,
+      std::optional<absl::string_view> media_type) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_H_
diff --git a/validator/asset_validator_impl.cc b/validator/asset_validator_impl.cc
new file mode 100644
index 0000000..2e44de8
--- /dev/null
+++ b/validator/asset_validator_impl.cc
@@ -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.
+//
+
+#include "validator/asset_validator_impl.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "bindings/validator.h"
+#include "formats/core_registry.h"
+#include "formats/extractor.h"
+#include "formats/format.h"
+#include "formats/registry.h"
+#include "proto/validation_result.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "validator/manifest_store_validator.h"
+#include "validator/manifest_store_validator_impl.h"
+#include "validator/result.h"
+#include "validator/validation_result_internal.h"
+#include "validator/validator_metrics.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+namespace {
+
+absl::StatusOr<std::unique_ptr<ValidationResult>> FinalizePartialResult(
+    std::unique_ptr<PartialValidationResultProto> partial_validation_result,
+    riegeli::Reader& input, const Format& format,
+    std::optional<absl::string_view> media_type,
+    ValidatorMetrics* absl_nullable metrics, std::string manifest_store_bytes) {
+  std::unique_ptr<ValidationResultProto> validation_result;
+  if (partial_validation_result->has_hard_binding_uri()) {
+    if (!input.Seek(0)) {
+      return absl::InternalError(
+          "Failed to seek to the beginning of the input.");
+    }
+    ABSL_ASSIGN_OR_RETURN(
+        validation_result,
+        ContentBindingValidator().Validate(
+            input, format, std::move(partial_validation_result)));
+  } else {
+    ABSL_ASSIGN_OR_RETURN(
+        validation_result,
+        MakeFullValidationResult(std::move(partial_validation_result)));
+  }
+  if (media_type.has_value()) {
+    validation_result->set_media_type(*media_type);
+  }
+
+  if (metrics != nullptr) {
+    metrics->RecordValidationResult(
+        media_type.has_value() ? *media_type : format.mime_types()[0],
+        *validation_result);
+  }
+
+  return std::make_unique<ValidationResult>(std::move(validation_result),
+                                            std::move(manifest_store_bytes));
+}
+
+ValidatorOptions OptionsWithoutMetrics(ValidatorOptions options) {
+  options.metrics = nullptr;
+  return options;
+}
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ValidationResult>> AssetValidatorImpl::Validate(
+    riegeli::Reader& input, std::optional<absl::string_view> media_type) const {
+  if (!input.SupportsSize() || !input.SupportsRandomAccess()) {
+    return absl::InvalidArgumentError(
+        "The input does not support backwards seeks, it is required.");
+  }
+
+  if (!input.Seek(0)) {
+    return absl::InternalError("Failed to seek to the beginning of the input.");
+  }
+  const Format* format;
+  if (media_type.has_value()) {
+    ABSL_ASSIGN_OR_RETURN(format,
+                          format_registry_->GetFormat(*media_type, input));
+  } else {
+    ABSL_ASSIGN_OR_RETURN(format, format_registry_->GetFormat(input));
+  }
+
+  if (!input.Seek(0)) {
+    return absl::InternalError("Failed to seek to the beginning of the input.");
+  }
+  ABSL_ASSIGN_OR_RETURN(auto manifest_store,
+                        format->extractor()->ExtractManifestStore(input));
+
+  absl::string_view manifest_str = manifest_store;
+
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<PartialValidationResultProto> partial_validation_result,
+      manifest_store_validator_->Validate(manifest_str));
+
+  return FinalizePartialResult(std::move(partial_validation_result), input,
+                               *format, media_type, options_.metrics,
+                               std::move(manifest_store));
+}
+
+AssetValidatorImpl::AssetValidatorImpl(ValidatorOptions options)
+    : options_(options),
+      format_registry_(CreateCoreFormatRegistry()),
+      manifest_store_validator_(std::make_unique<ManifestStoreValidatorImpl>(
+          OptionsWithoutMetrics(std::move(options)))) {}
+
+}  // namespace credentio
diff --git a/validator/asset_validator_impl.h b/validator/asset_validator_impl.h
new file mode 100644
index 0000000..ea54c3c
--- /dev/null
+++ b/validator/asset_validator_impl.h
@@ -0,0 +1,60 @@
+// 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_VALIDATOR_ASSET_VALIDATOR_IMPL_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_IMPL_H_
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+
+#include "absl/base/nullability.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/registry.h"
+#include "riegeli/bytes/reader.h"
+#include "validator/asset_validator.h"
+#include "validator/manifest_store_validator.h"
+#include "validator/result.h"
+#include "validator/validator_metrics.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+
+class AssetValidatorImpl : public AssetValidator {
+ public:
+  explicit AssetValidatorImpl(ValidatorOptions options);
+
+  absl::StatusOr<std::unique_ptr<ValidationResult>> Validate(
+      riegeli::Reader& input,
+      std::optional<absl::string_view> media_type) const override;
+
+ private:
+  struct Options {
+    int64_t max_chunk_bytes;
+    ValidatorMetrics* absl_nullable metrics;
+
+    explicit Options(const ValidatorOptions& options)
+        : max_chunk_bytes(options.max_chunk_bytes), metrics(options.metrics) {}
+  };
+
+  Options options_;
+  std::unique_ptr<FormatRegistry> format_registry_;
+  std::unique_ptr<ManifestStoreValidator> manifest_store_validator_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_ASSET_VALIDATOR_IMPL_H_
diff --git a/validator/graph.cc b/validator/graph.cc
new file mode 100644
index 0000000..d04ca47
--- /dev/null
+++ b/validator/graph.cc
@@ -0,0 +1,545 @@
+// 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 "validator/graph.h"
+
+#include <cstddef>
+#include <memory>
+#include <optional>
+#include <stack>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/log/check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_join.h"
+#include "absl/strings/string_view.h"
+#include "assertion/hashed_uri_validator.h"
+#include "constants/ingredient_relationships.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "jumbf/box.h"
+#include "proto/assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "uuid/uuid.h"
+#include "validator/graph_internals.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+template <typename T>
+const Manifest* GetManifest(const T* result, absl::string_view label) {
+  if (result->active_manifest().label() == label) {
+    return &result->active_manifest();
+  }
+  for (const auto& ingredient_manifest : result->ingredient_manifests()) {
+    if (ingredient_manifest.label() == label) {
+      return &ingredient_manifest;
+    }
+  }
+  return nullptr;
+}
+
+bool IsIngredientAssertion(const Assertion& assertion) {
+  return assertion.has_ingredient_v3();
+}
+
+// Returns true if the given label is a hard binding assertion label.
+// Does not match multi-asset hash assertions or the "part hash" assertions they
+// reference.
+
+// Returns true if the given label is for a hard binding assertion, a
+// multi-asset hash assertion, or a "part hash" assertion.
+
+std::optional<HashedUri> GetIngredientManifestHashedUri(
+    const Assertion& assertion) {
+  if (assertion.ingredient_v3().has_active_manifest()) {
+    return assertion.ingredient_v3().active_manifest();
+  }
+  return std::nullopt;
+}
+
+absl::string_view GetIngredientRelationship(const Assertion& assertion) {
+  return assertion.ingredient_v3().relationship();
+}
+
+std::string GetAssertionUri(absl::string_view manifest_label,
+                            const Assertion& assertion) {
+  return absl::StrJoin({absl::StrCat(kManifestStoreUrlPrefix, manifest_label),
+                        kAssertionStoreLabel, assertion.label()},
+                       kManifestLabelDelimiter);
+}
+
+// No failures are recorded in the tracker for this method as these failures
+// are recorded during the ValidateNode call.
+std::optional<std::string> GetHardBindingUri(
+    const PartialValidationResultProto& result,
+    absl::string_view manifest_label, ValidationTracker& tracker) {
+  if (manifest_label.empty()) {
+    return std::nullopt;
+  }
+  auto* manifest_with_content_bindings = GetManifest(&result, manifest_label);
+  if (manifest_with_content_bindings == nullptr) {
+    return std::nullopt;
+  }
+
+  std::optional<std::string> uri;
+  for (const auto& assertion : manifest_with_content_bindings->assertions()) {
+    if ((assertion.has_bmff_based_hash() || assertion.has_boxes_hash() ||
+         assertion.has_data_hash() || assertion.has_collection_data_hash()) &&
+        !absl::StrContains(assertion.label(), ".part")) {
+      uri = GetAssertionUri(manifest_label, assertion);
+      break;
+    }
+  }
+  return uri;
+}
+
+// No failures are recorded in the tracker for this method as these failures
+// are recorded during the ValidateNode call.
+std::optional<std::string> GetMultiAssetHashUri(
+    const PartialValidationResultProto& result,
+    absl::string_view manifest_label, ValidationTracker& tracker) {
+  if (manifest_label.empty()) {
+    return std::nullopt;
+  }
+  auto* manifest_with_content_bindings = GetManifest(&result, manifest_label);
+  if (manifest_with_content_bindings == nullptr) {
+    return std::nullopt;
+  }
+
+  std::optional<std::string> uri;
+  for (const auto& assertion : manifest_with_content_bindings->assertions()) {
+    if (assertion.has_multi_asset_hash()) {
+      uri = GetAssertionUri(manifest_label, assertion);
+      break;
+    }
+  }
+  return uri;
+}
+
+void SetHardBindingUri(absl::string_view manifest_with_content_bindings_label,
+                       PartialValidationResultProto& result) {
+  ValidationTracker tracker(result.mutable_active_manifest());
+
+  if (result.active_manifest().label().empty()) {
+    return;
+  }
+  if (auto hard_binding_uri = GetHardBindingUri(
+          result, manifest_with_content_bindings_label, tracker);
+      hard_binding_uri.has_value()) {
+    result.set_hard_binding_uri(*hard_binding_uri);
+  }
+  if (auto multi_asset_hash_uri = GetMultiAssetHashUri(
+          result, manifest_with_content_bindings_label, tracker);
+      multi_asset_hash_uri.has_value()) {
+    result.set_multi_asset_hash_uri(*multi_asset_hash_uri);
+  }
+}
+
+// Returns the containing manifest path from the given assertion path, or
+// nullopt if the assertion path is invalid.
+std::optional<absl::string_view> ManifestPathFromAssertionPath(
+    absl::string_view path) {
+  // Absolute assertion path should look like `/c2pa/<manifest
+  // label>/c2pa.assertions/...`. Find the position of the third slash to
+  // extract the manifest path.
+  if (path.empty() || path[0] != '/') {
+    return std::nullopt;
+  }
+  size_t second_slash = path.find('/', 1);
+  if (second_slash == std::string::npos) {
+    return std::nullopt;
+  }
+  size_t third_slash = path.find('/', second_slash + 1);
+  if (third_slash == std::string::npos) {
+    return std::nullopt;
+  }
+  return path.substr(0, third_slash);
+}
+
+absl::string_view ManifestLabel(const jumbf::SuperBox& node) {
+  return node.description.label.value_or("");
+}
+
+std::string ManifestPath(const jumbf::SuperBox& node) {
+  return absl::StrCat("/c2pa/", ManifestLabel(node));
+}
+
+// Applies checks based on the manifest type, returning true if processing
+// should continue.
+bool CheckManifestType(Manifest& manifest, credentio::Uuid type_uuid,
+                       std::string* manifest_with_content_bindings_label,
+                       ValidationTracker& tracker) {
+  if (type_uuid == kStandardManifestUuid) {
+    if (manifest_with_content_bindings_label->empty()) {
+      // This is either the active manifest or the first standard manifest
+      // found by following the chain of parent ingredients from the active
+      // manifest.
+      *manifest_with_content_bindings_label = manifest.label();
+    }
+    return true;
+  }
+  if (type_uuid == kUpdateManifestUuid) {
+    // Update manifest checks are performed in the assertion validator.
+    manifest.set_is_update_manifest(true);
+    return true;
+  }
+  if (type_uuid == kCompressedManifestUuid) {
+    tracker.RecordFailure(
+        FailureStatusCode::kGoogleUnsupportedManifestType,
+        {.explanation = "Compressed manifests not supported"});
+    return false;
+  }
+  if (type_uuid == kTimestampManifestUuid) {
+    // No need to support time-stamp manifests (deprecated in C2PA 2.2).
+    tracker.RecordFailure(FailureStatusCode::kGoogleUnsupportedManifestType,
+                          {.explanation = "Timestamp manifests not supported"});
+    return false;
+  }
+  tracker.RecordFailure(
+      FailureStatusCode::kGoogleUnsupportedManifestType,
+      {.explanation =
+           absl::StrCat("Unrecognized manifest type: ", type_uuid.ToString())});
+  return false;
+}
+
+// Returns true if the manifest label uses a legacy (pre-C2PA-2.1) format.
+bool IsLegacyManifestLabel(absl::string_view manifest_label) {
+  return !absl::StartsWith(manifest_label, "urn:c2pa");
+}
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+ManifestGraph::Validate() {
+  ABSL_RETURN_IF_ERROR(ValidateGraph());
+  ABSL_ASSIGN_OR_RETURN(auto result, ValidatePostProcess());
+  // At this point, the set of redacted assertions and the set of ingredient
+  // manifests should be fully populated. We can perform the second pass of the
+  // ingredient validation algorithm in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_performing_explicit_validation.
+  return ValidateWithRedactions(std::move(result));
+}
+
+absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+ManifestGraph::ValidatePostProcess() {
+  auto result = std::make_unique<PartialValidationResultProto>();
+  for (const auto& ingredient_manifest_path : ingredient_manifest_paths_) {
+    ABSL_ASSIGN_OR_RETURN(auto manifest_state,
+                          GetValidatedManifestState(ingredient_manifest_path));
+    absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>
+        existing_failures;
+    existing_failures.reserve(
+        manifest_state->manifest.validation().failures_size());
+    for (const auto& failure :
+         manifest_state->manifest.validation().failures()) {
+      existing_failures.insert(std::make_pair(failure.code(), failure.url()));
+    }
+    for (const IngredientAssertion& assertion :
+         manifest_state->referencing_ingredient_assertions) {
+      PropagateFailuresFromIngredientAssertion(
+          assertion.assertion->ingredient_v3(),
+          *manifest_state->manifest.mutable_validation(), existing_failures);
+    }
+    *result->add_ingredient_manifests() = std::move(manifest_state->manifest);
+  }
+  ABSL_ASSIGN_OR_RETURN(
+      auto active_manifest_state,
+      GetValidatedManifestState(ManifestPath(active_manifest_)));
+  *result->mutable_active_manifest() =
+      std::move(active_manifest_state->manifest);
+  SetHardBindingUri(manifest_with_content_bindings_label_, *result);
+  return result;
+}
+
+std::optional<std::string> ManifestGraph::ProcessIngredientAssertion(
+    const Assertion& assertion, absl::string_view manifest_path,
+    absl::string_view default_algorithm, ValidationTracker& tracker) {
+  const std::string assertion_url = absl::StrCat(
+      kAssertionStoreLabel, kManifestLabelDelimiter, assertion.label());
+  std::optional<HashedUri> manifest_hashed_uri =
+      GetIngredientManifestHashedUri(assertion);
+  if (!manifest_hashed_uri.has_value()) {
+    // Record informational code if the ingredient assertion does not have
+    // an `active_manifest` field, unless relationship is `inputTo` (Step
+    // 7-a-iii in the algorithm).
+    if (GetIngredientRelationship(assertion) !=
+        kIngredientRelationshipInputTo) {
+      tracker.RecordInformational(
+          InformationalStatusCode::kIngredientUnknownProvenance,
+          {.url = assertion_url});
+    }
+    return std::nullopt;
+  }
+  auto absolute_path = uri_resolver_.GetAbsolutePathFromUri(
+      manifest_hashed_uri->url(), manifest_path);
+  if (!absolute_path.ok()) {
+    tracker.RecordFailure(FailureStatusCode::kIngredientManifestMissing,
+                          {.url = assertion_url});
+    return std::nullopt;
+  }
+  if (assertion.ingredient_v3().has_claim_signature()) {
+    // Validate the claim signature hash.
+    const auto& claim_signature = assertion.ingredient_v3().claim_signature();
+    HashedUriValidator claim_signature_hash_validator(
+        std::string(manifest_path), uri_resolver_, &hash_checker_factory_);
+    claim_signature_hash_validator.SetErrorCodes(HashedUriValidator::Codes{
+        .missing = FailureStatusCode::kIngredientClaimSignatureMissing,
+        .mismatch = FailureStatusCode::kIngredientClaimSignatureMismatch});
+    claim_signature_hash_validator.SetDefaultAlgorithm(
+        std::string(default_algorithm));
+    auto path = claim_signature_hash_validator.Validate(claim_signature,
+                                                        assertion_url, tracker);
+    if (!path.has_value()) {
+      return std::nullopt;  // Validation failure status has been recorded.
+    }
+    tracker.RecordSuccess(SuccessStatusCode::kIngredientClaimSignatureValidated,
+                          {.url = assertion_url});
+  } else {
+    // Validate the manifest hash.
+    HashedUriValidator manifest_hash_validator(
+        std::string(manifest_path), uri_resolver_, &hash_checker_factory_);
+    manifest_hash_validator.SetErrorCodes(HashedUriValidator::Codes{
+        .missing = FailureStatusCode::kIngredientManifestMissing,
+        .mismatch = FailureStatusCode::kIngredientManifestMismatch});
+    manifest_hash_validator.SetDefaultAlgorithm(std::string(default_algorithm));
+    auto path = manifest_hash_validator.Validate(*manifest_hashed_uri,
+                                                 assertion_url, tracker);
+    if (!path.has_value()) {
+      return std::nullopt;  // Validation failure status has been recorded.
+    }
+    tracker.RecordSuccess(SuccessStatusCode::kIngredientManifestValidated,
+                          {.url = assertion_url});
+  }
+  return *absolute_path;
+}
+
+ManifestGraph::ManifestState& ManifestGraph::GetOrCreateManifestState(
+    absl::string_view path) {
+  auto [it, inserted] = manifests_.insert({std::string(path), nullptr});
+  if (inserted) {
+    it->second = std::make_unique<ManifestState>();
+  }
+  return *it->second;
+}
+
+absl::StatusOr<ManifestGraph::ManifestState* absl_nonnull>
+ManifestGraph::GetValidatedManifestState(absl::string_view path) {
+  auto it = manifests_.find(path);
+  if (it == manifests_.end()) {
+    return absl::InternalError(
+        absl::StrCat("manifest state not found: ", path));
+  }
+  if (it->second->validation_state != InternalValidationState::kValidated) {
+    return absl::InternalError(
+        absl::StrCat("manifest is not yet validated: ", path));
+  }
+  return it->second.get();
+}
+
+absl::Status ManifestGraph::ValidateGraph() {
+  std::stack<const jumbf::SuperBox*> to_visit;
+  to_visit.push(&active_manifest_);
+  bool is_active_manifest = true;
+  while (!to_visit.empty()) {
+    const jumbf::SuperBox* node = to_visit.top();
+    to_visit.pop();
+    ABSL_RETURN_IF_ERROR(ValidateNode(*node,
+                                      /*is_active_manifest=*/is_active_manifest,
+                                      to_visit));
+    is_active_manifest = false;
+  }
+  return absl::OkStatus();
+}
+
+// Validates the claim and assertions for the given "node" that represents a
+// manifest in the manifest graph. Calling this function for a node is similar
+// to jumping to Step 3 in the ingredient validation algorithm in
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_performing_explicit_validation
+// for a target manifest represented by this node in the manifest graph.
+//
+// The method:
+// * Locates and validates the claim (Step 4 in the algorithm).
+// * Updates the set of redacted assertions (Step 5 in the algorithm).
+// * Validates all assertions in the claim (Step 6 in the algorithm).
+// * Finally, the method pushes all eligible ingredient manifests onto the
+//   `to_visit` stack (Step 7 in the algorithm).
+absl::Status ManifestGraph::ValidateNode(
+    const jumbf::SuperBox& node, bool is_active_manifest,
+    std::stack<const jumbf::SuperBox*>& to_visit) {
+  absl::string_view manifest_label = ManifestLabel(node);
+  std::string manifest_path = ManifestPath(node);
+  ManifestState& manifest_state = GetOrCreateManifestState(manifest_path);
+  if (manifest_state.validation_state == InternalValidationState::kValidated) {
+    return absl::OkStatus();
+  }
+  if (!is_active_manifest) ingredient_manifest_paths_.push_back(manifest_path);
+  InternalValidationStateLock validation_state_lock(&manifest_state);
+
+  Manifest& manifest = manifest_state.manifest;
+  manifest.set_label(manifest_label);
+  ValidationTracker tracker(&manifest);
+  {
+    if (IsLegacyManifestLabel(manifest_label)) {
+      tracker.RecordFailure(
+          FailureStatusCode::kGoogleUnsupportedSpecVersion,
+          {.explanation = "deprecated manifest label format"});
+      return absl::OkStatus();
+    }
+    if (!CheckManifestType(manifest, node.description.type_uuid,
+                           &manifest_with_content_bindings_label_, tracker)) {
+      return absl::OkStatus();
+    }
+
+    auto validated_claim = claim_validator_.Validate(node, tracker);
+    if (!validated_claim.has_value()) {
+      return absl::OkStatus();
+    }
+    *manifest.mutable_claim() = *std::move(validated_claim);
+    // Update the set of redacted assertions.
+    for (const auto& assertion : manifest.claim().redacted_assertions()) {
+      ProcessRedaction(assertion, manifest_path, tracker);
+    }
+    //
+    // Validate assertions and populate `manifest.assertions`.
+    assertion_validator_.ValidateClaimAssertions(
+        uri_resolver_, manifest_state.redacted_assertions, manifest, tracker);
+    // Find ingredient manifests from the validated ingredient assertions and
+    // add them to the `ingredients` field of the result.
+    std::vector<std::string> ingredients;
+    for (const auto& assertion : manifest.assertions()) {
+      if (IsIngredientAssertion(assertion)) {
+        auto ingredient_manifest_path = ProcessIngredientAssertion(
+            assertion, manifest_path,
+            /*default_algorithm=*/
+            manifest.claim().default_algorithm(), tracker);
+        if (ingredient_manifest_path.has_value()) {
+          auto ingredient_manifest_box =
+              uri_resolver_.ResolvePath(*ingredient_manifest_path);
+          if (!ingredient_manifest_box.ok() ||
+              *ingredient_manifest_box == nullptr) {
+            tracker.RecordFailure(
+                FailureStatusCode::kIngredientManifestMissing,
+                {.url =
+                     absl::StrCat(kAssertionStoreLabel, kManifestLabelDelimiter,
+                                  assertion.label()),
+                 .explanation = absl::StrCat(
+                     "Failed to resolve the ingredient manifest URI: ",
+                     *ingredient_manifest_path)});
+          } else {
+            ManifestState& ingredient_manifest_state =
+                GetOrCreateManifestState(*ingredient_manifest_path);
+            ingredient_manifest_state.referencing_ingredient_assertions
+                .push_back(IngredientAssertion{.assertion = &assertion,
+                                               .assertion_uri = GetAssertionUri(
+                                                   manifest_label, assertion)});
+            // Update the set of ingredients that will be recursed into.
+            if (ingredient_manifest_state.validation_state !=
+                InternalValidationState::kValidated) {
+              ingredients.push_back(*ingredient_manifest_path);
+            }
+          }
+        }
+      }
+    }
+    // Push ingredient manifests onto the stack for depth-first traversal.
+    for (; !ingredients.empty(); ingredients.pop_back()) {
+      const std::string& ingredient_manifest = ingredients.back();
+      auto ingredient_manifest_box =
+          uri_resolver_.ResolvePath(ingredient_manifest);
+      if (!ingredient_manifest_box.ok() ||
+          *ingredient_manifest_box == nullptr) {
+        // This should never happen because we have already validated the
+        // ingredient manifest URI.
+        continue;
+      }
+      to_visit.push(*ingredient_manifest_box);
+    }
+  }
+  if (manifest.validation().failures_size() == 0 &&
+      manifest.validation().successes_size() == 0) {
+    tracker.RecordFailure(FailureStatusCode::kGoogleInternalError,
+                          {.explanation = "No failure or success "
+                                          "status codes were recorded."});
+  }
+  return absl::OkStatus();
+}
+
+absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+ManifestGraph::ValidateWithRedactions(
+    std::unique_ptr<PartialValidationResultProto> result) {
+  return result;
+}
+
+void ManifestGraph::ProcessRedaction(absl::string_view redacted_assertion_uri,
+                                     absl::string_view manifest_path,
+                                     ValidationTracker& tracker) {
+  // Resolve the redacted assertion URI to an absolute path before adding it
+  // to the set.
+  absl::StatusOr<std::string> absolute_path =
+      uri_resolver_.GetAbsolutePathFromUri(redacted_assertion_uri,
+                                           manifest_path);
+  if (!absolute_path.ok()) {
+    // Malformed URI.
+    tracker.RecordFailure(FailureStatusCode::kAssertionMissing, {});
+    return;
+  }
+  // Check if the redacted assertion URI points into the manifest whose claim
+  // redacted it.
+  if (absl::StartsWith(*absolute_path, manifest_path)) {
+    tracker.RecordFailure(FailureStatusCode::kAssertionSelfRedacted, {});
+    return;
+  }
+  std::optional<absl::string_view> redacted_assertion_manifest_path =
+      ManifestPathFromAssertionPath(*absolute_path);
+  if (!redacted_assertion_manifest_path.has_value()) {
+    // Malformed path.
+    tracker.RecordFailure(FailureStatusCode::kAssertionMissing, {});
+    return;
+  }
+  GetOrCreateManifestState(*redacted_assertion_manifest_path)
+      .redacted_assertions.insert(*absolute_path);
+}
+
+ManifestGraph::InternalValidationStateLock::InternalValidationStateLock(
+    ManifestState* absl_nonnull manifest_state)
+    : manifest_state_(*manifest_state) {
+  DCHECK(manifest_state_.validation_state ==
+         InternalValidationState::kUnvalidated);
+  manifest_state_.validation_state = InternalValidationState::kBeingValidated;
+}
+
+ManifestGraph::InternalValidationStateLock::~InternalValidationStateLock() {
+  manifest_state_.validation_state = InternalValidationState::kValidated;
+}
+}  // namespace credentio
diff --git a/validator/graph.h b/validator/graph.h
new file mode 100644
index 0000000..a0189c3
--- /dev/null
+++ b/validator/graph.h
@@ -0,0 +1,171 @@
+// 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_VALIDATOR_GRAPH_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_H_
+
+#include <memory>
+#include <optional>
+#include <stack>
+#include <string>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "assertion/validator.h"
+#include "claim/validator.h"
+#include "crypto/hash.h"
+#include "jumbf/box.h"
+#include "jumbf/uri.h"
+#include "proto/assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "validator/tracker.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+
+class ManifestGraph {
+ public:
+  ManifestGraph(const jumbf::SuperBox* absl_nonnull active_manifest,
+                const jumbf::UriResolver* absl_nonnull uri_resolver,
+                const AssertionValidator* absl_nonnull assertion_validator,
+                const ClaimValidator* absl_nonnull claim_validator,
+                const HashCheckerFactory* absl_nonnull hash_checker_factory,
+                const ValidatorOptions* absl_nonnull options)
+      : active_manifest_(*ABSL_DIE_IF_NULL(active_manifest)),
+        uri_resolver_(*ABSL_DIE_IF_NULL(uri_resolver)),
+        assertion_validator_(*ABSL_DIE_IF_NULL(assertion_validator)),
+        claim_validator_(*ABSL_DIE_IF_NULL(claim_validator)),
+        hash_checker_factory_(*ABSL_DIE_IF_NULL(hash_checker_factory)),
+        options_(*ABSL_DIE_IF_NULL(options)) {}
+
+  // Perform claim and assertion validation for the active manifest and all
+  // ingredient manifests reachable from the active manifest. Caller should
+  // follow with calling the `Validate` method from
+  // of bindings/validator.h to validate
+  // content bindings.
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> Validate();
+
+ private:
+  // Internal validation state of a manifest, tracking whether the manifest has
+  // been processed by `ValidateInternal` yet.
+  enum class InternalValidationState {
+    // `ValidateInternal` has not processed the manifest yet.
+    kUnvalidated,
+    // `ValidateInternal` is currently processing the manifest.
+    kBeingValidated,
+    // `ValidateInternal` has processed the manifest already.
+    kValidated,
+  };
+
+  struct IngredientAssertion {
+    const Assertion* assertion = nullptr;
+    std::string assertion_uri;
+  };
+
+  // State associated with a specific manifest during validation.
+  struct ManifestState {
+    InternalValidationState validation_state =
+        InternalValidationState::kUnvalidated;
+    // Absolute paths of all assertions in this manifest that have been reported
+    // (in another manifest) as redacted.
+    absl::flat_hash_set<std::string> redacted_assertions;
+    // Ingredient assertions that reference this manifest.
+    std::vector<IngredientAssertion> referencing_ingredient_assertions;
+
+    // Validation result, complete if `validation_state` is kValidated.
+    Manifest manifest;
+  };
+
+  // Returns the manifest state for the given path, creating it if it does not
+  // exist.
+  ManifestState& GetOrCreateManifestState(absl::string_view path);
+  // Returns the manifest state for the given path, or an error if the manifest
+  // is not yet validated.
+  absl::StatusOr<ManifestState* absl_nonnull> GetValidatedManifestState(
+      absl::string_view path);
+
+  // Performs the first pass of the ingredient validation algorithm in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_performing_explicit_validation
+  // for the active manifest to populate the set of redacted assertions and the
+  // set of ingredient manifests and to perform claim and (non-redacted)
+  // assertion validation for all manifests in the graph.
+  absl::Status ValidateGraph();
+  absl::Status ValidateNode(const jumbf::SuperBox& node,
+                            bool is_active_manifest,
+                            std::stack<const jumbf::SuperBox*>& to_visit);
+
+  // This method performs the second pass of the ingredient validation algorithm
+  // in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_performing_explicit_validation.
+  // In this pass, we perform the hash validation checks for all ingredient
+  // manifests reachable from the active manifest, using claim signature or
+  // manifest hash validation methods. We also process the `validationResults`
+  // sections of v3 ingredient assertions and update the
+  // `PartialValidationResultProto` accordingly.
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+  ValidateWithRedactions(std::unique_ptr<PartialValidationResultProto> result);
+
+  // Records the given redaction in the `redacted_assertions_` map.
+  void ProcessRedaction(absl::string_view redacted_assertion_uri,
+                        absl::string_view manifest_path,
+                        ValidationTracker& tracker);
+
+  // Processes the given ingredient assertion, returning the absolute path to
+  // the ingredient's manifest if it should be processed.
+  std::optional<std::string> ProcessIngredientAssertion(
+      const Assertion& assertion, absl::string_view manifest_path,
+      absl::string_view default_algorithm, ValidationTracker& tracker);
+
+  // Performs post-processing to complete population of the validation result
+  // following graph traversal via ValidateNode.
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+  ValidatePostProcess();
+
+  // RAII-style lock to manage validation state transitions using local scope.
+  class InternalValidationStateLock {
+   public:
+    explicit InternalValidationStateLock(
+        ManifestState* absl_nonnull manifest_state);
+
+    ~InternalValidationStateLock();
+
+   private:
+    ManifestState& manifest_state_;
+  };
+
+  const jumbf::SuperBox& active_manifest_;
+  const jumbf::UriResolver& uri_resolver_;
+  const AssertionValidator& assertion_validator_;
+  const ClaimValidator& claim_validator_;
+  const HashCheckerFactory& hash_checker_factory_;
+  const ValidatorOptions& options_;
+  // Manifest validation states, keyed by manifest path.
+  absl::flat_hash_map<std::string, std::unique_ptr<ManifestState>> manifests_;
+  // Ingredient manifest paths, in pre-order traversal ordering.
+  std::vector<std::string> ingredient_manifest_paths_;
+  std::string manifest_with_content_bindings_label_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_H_
diff --git a/validator/graph_internals.cc b/validator/graph_internals.cc
new file mode 100644
index 0000000..11bf4e8
--- /dev/null
+++ b/validator/graph_internals.cc
@@ -0,0 +1,62 @@
+// 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 "validator/graph_internals.h"
+
+#include <string>
+#include <utility>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+namespace {
+
+ValidationStatus FromOriginalValidator(ValidationStatus status) {
+  std::string explanation = "from original validator";
+  if (!status.explanation().empty()) {
+    absl::StrAppend(&explanation, ": ", status.explanation());
+  }
+  status.set_explanation(std::move(explanation));
+  return status;
+}
+
+}  // namespace
+
+void PropagateFailuresFromIngredientAssertion(
+    const IngredientAssertionV3& ingredient_assertion,
+    ValidationStatusSet& validation,
+    absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>&
+        existing_failures) {
+  const auto& failures =
+      ingredient_assertion.validation_results().active_manifest().failures();
+  if (failures.empty()) {
+    return;
+  }
+  for (const auto& failure : failures) {
+    if (validation.failures_size() >= 10000) {
+      break;
+    }
+    if (existing_failures.insert(std::make_pair(failure.code(), failure.url()))
+            .second) {
+      *validation.add_failures() = FromOriginalValidator(failure);
+    }
+  }
+}
+
+}  // namespace credentio
diff --git a/validator/graph_internals.h b/validator/graph_internals.h
new file mode 100644
index 0000000..ea927ab
--- /dev/null
+++ b/validator/graph_internals.h
@@ -0,0 +1,43 @@
+// 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.
+//
+
+// Implementation support for ManifestGraph.
+#ifndef THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_INTERNALS_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_INTERNALS_H_
+
+#include <utility>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/strings/string_view.h"
+#include "proto/assertion.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+
+// Copies any failure statuses from the ingredient assertion to the given
+// validation results. It is important to treat these failures (from the claim
+// generator that produced the ingredient assertion) as validation failures
+// for the ingredient, because (particularly for content binding failures) the
+// current validator may not be able to perform the check itself.
+void PropagateFailuresFromIngredientAssertion(
+    const IngredientAssertionV3& assertion,
+    ValidationStatusSet& ingredient_validation,
+    absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>&
+        existing_failures);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_GRAPH_INTERNALS_H_
diff --git a/validator/graph_internals_test.cc b/validator/graph_internals_test.cc
new file mode 100644
index 0000000..f7f5f52
--- /dev/null
+++ b/validator/graph_internals_test.cc
@@ -0,0 +1,217 @@
+// 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 "validator/graph_internals.h"
+
+#include <utility>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/validation_status.pb.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+
+TEST(PropagateFailuresFromIngredientAssertionTest, V3IngredientWithFailures) {
+  auto assertion = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb(
+    validation_results {
+      active_manifest {
+        failures {
+          code: "assertion.dataHash.mismatch"
+          url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.assertions/c2pa.hash.data"
+        }
+        failures {
+          code: "assertion.missing"
+          url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.assertions/c2pa.actions"
+          explanation: "can't find assertion"
+        }
+        successes {
+          code: "timeStamp.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.signature"
+        }
+      }
+    }
+  )pb");
+  ValidationStatusSet validation;
+  absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>
+      existing_failures;
+  PropagateFailuresFromIngredientAssertion(assertion, validation,
+                                           existing_failures);
+  EXPECT_THAT(
+      validation, EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb(
+        failures {
+          code: "assertion.dataHash.mismatch"
+          url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.assertions/c2pa.hash.data"
+          explanation: "from original validator"
+        }
+        failures {
+          code: "assertion.missing"
+          url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.assertions/c2pa.actions"
+          explanation: "from original validator: can't find assertion"
+        }
+      )pb")));
+}
+
+TEST(PropagateFailuresFromIngredientAssertionTest, DedupFailures) {
+  auto assertion = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb(
+    validation_results {
+      active_manifest {
+        failures {
+          code: "assertion.dataHash.mismatch"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data"
+          explanation: "explanation 1"
+        }
+        # duplicated in ValidationStatusSet below, will be dropped
+        failures {
+          code: "assertion.missing"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.actions.v2"
+          explanation: "explanation 2"
+        }
+        failures {
+          code: "assertion.missing"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.metadata"
+          explanation: "explanation 3"
+        }
+        successes {
+          code: "timeStamp.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:b5689eb7-36d2-5fc5-2ce8-495a0aebfa1c/c2pa.signature"
+        }
+      }
+    }
+  )pb");
+  auto validation = ParseTextProtoOrDie<ValidationStatusSet>(R"pb(
+    failures {
+      code: "assertion.dataHash.malformed"
+      url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data"
+      explanation: "explanation 4"
+    }
+    failures {
+      code: "assertion.missing"
+      url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.actions.v2"
+      explanation: "explanation 5"
+    }
+    failures {
+      code: "assertion.missing"
+      url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.metadata__1"
+      explanation: "explanation 6"
+    }
+  )pb");
+  absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>
+      existing_failures;
+  for (const auto& failure : validation.failures()) {
+    existing_failures.insert(std::make_pair(failure.code(), failure.url()));
+  }
+  PropagateFailuresFromIngredientAssertion(assertion, validation,
+                                           existing_failures);
+  EXPECT_THAT(
+      validation, EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb(
+        failures {
+          code: "assertion.dataHash.malformed"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data"
+          explanation: "explanation 4"
+        }
+        failures {
+          code: "assertion.missing"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.actions.v2"
+          explanation: "explanation 5"
+        }
+        failures {
+          code: "assertion.missing"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.metadata__1"
+          explanation: "explanation 6"
+        }
+        failures {
+          code: "assertion.dataHash.mismatch"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data"
+          explanation: "from original validator: explanation 1"
+        }
+        failures {
+          code: "assertion.missing"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.metadata"
+          explanation: "from original validator: explanation 3"
+        }
+      )pb")));
+}
+
+TEST(PropagateFailuresFromIngredientAssertionTest, RepeatedCallsDedup) {
+  auto assertion1 = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb(
+    validation_results {
+      active_manifest {
+        failures {
+          code: "assertion.dataHash.mismatch"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data"
+          explanation: "explanation 1"
+        }
+      }
+    }
+  )pb");
+  auto assertion2 = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb(
+    validation_results {
+      active_manifest {
+        failures {
+          code: "assertion.dataHash.mismatch"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data"
+          explanation: "explanation 2"
+        }
+      }
+    }
+  )pb");
+  ValidationStatusSet validation;
+  absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>
+      existing_failures;
+  PropagateFailuresFromIngredientAssertion(assertion1, validation,
+                                           existing_failures);
+  PropagateFailuresFromIngredientAssertion(assertion2, validation,
+                                           existing_failures);
+  EXPECT_EQ(validation.failures_size(), 1);
+  EXPECT_EQ(validation.failures(0).explanation(),
+            "from original validator: explanation 1");
+}
+
+TEST(PropagateFailuresFromIngredientAssertionTest, MaxFailuresLimit) {
+  ValidationStatusSet validation;
+  absl::flat_hash_set<std::pair<absl::string_view, absl::string_view>>
+      existing_failures;
+  for (int i = 0; i < 10000; ++i) {
+    auto* failure = validation.add_failures();
+    failure->set_code(absl::StrCat("code_", i));
+    failure->set_url(absl::StrCat("url_", i));
+    existing_failures.insert(std::make_pair(failure->code(), failure->url()));
+  }
+  auto assertion = ParseTextProtoOrDie<IngredientAssertionV3>(R"pb(
+    validation_results {
+      active_manifest {
+        failures {
+          code: "new.failure"
+          url: "self#jumbf=/c2pa/manifest1/c2pa.assertions/c2pa.hash.data"
+        }
+      }
+    }
+  )pb");
+  PropagateFailuresFromIngredientAssertion(assertion, validation,
+                                           existing_failures);
+  EXPECT_EQ(validation.failures_size(), 10000);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/validator/graph_test.cc b/validator/graph_test.cc
new file mode 100644
index 0000000..2316c92
--- /dev/null
+++ b/validator/graph_test.cc
@@ -0,0 +1,1926 @@
+// 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 "validator/graph.h"
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"  // IWYU pragma: keep
+#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 "absl/strings/substitute.h"
+#include "absl/types/span.h"
+#include "assertion/validator.h"
+#include "constants/status_codes.h"
+#include "crypto/algorithms.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/uri.h"
+#include "proto/assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "testing/fake_assertion_validator.h"
+#include "testing/fake_claim_validator.h"
+#include "testing/jumbf_utils.h"
+#include "testing/mock_assertion_validator.h"
+#include "testing/proto_test_utils.h"
+#include "validator/tracker.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::_;
+using ::testing::Contains;
+using ::testing::IsEmpty;
+using ::testing::Property;
+using ::testing::SizeIs;
+using ::testing::UnorderedElementsAre;
+
+class NoopHashChecker : public HashChecker {
+ public:
+  void Update(absl::string_view data) override {}
+  bool Check(absl::string_view hash) override { return true; }
+
+ private:
+  absl::string_view hash_value_;
+};
+
+class NoopHashCheckerFactory : public HashCheckerFactory {
+ public:
+  absl::StatusOr<std::unique_ptr<HashChecker>> Create(
+      HashAlgorithm algorithm) const override {
+    return std::make_unique<NoopHashChecker>();
+  }
+};
+
+Claim DefaultClaim() {
+  Claim claim;
+  claim.set_default_algorithm("sha256");
+  return claim;
+}
+
+class ManifestGraphTest : public ::testing::Test {
+ protected:
+  ManifestGraphTest()
+      : claim_signature_box_bytes_(
+            EncodeClaimSignatureBox("claim-signature-cbor")),
+        claim_signature_box_(ParseSuperBoxOrDie(&claim_signature_box_bytes_)),
+        empty_data_hash_assertion_(ParseTextProtoOrDie<Assertion>(
+            "label: 'c2pa.hash.data' data_hash {}")) {}
+
+  std::string claim_signature_box_bytes_;
+  jumbf::SuperBox claim_signature_box_;
+  NoopHashCheckerFactory noop_hash_checker_factory_;
+  ValidatorOptions options_;
+  Assertion empty_data_hash_assertion_;
+};
+
+TEST_F(ManifestGraphTest, ClaimValidationFails) {
+  FakeClaimValidator claim_validator({FailureStatusCode::kGeneralError},
+                                     /*informationals=*/{}, /*successes=*/{});
+  auto assertion_validator = std::make_unique<AssertionValidator>();
+  jumbf::SuperBox root_box;
+  jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {});
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  ManifestGraph graph(&active_manifest, &uri_resolver,
+                      assertion_validator.get(), &claim_validator,
+                      &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result,
+              EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(
+                  R"pb(active_manifest {
+                         label: "urn:c2pa:1"
+                         validation {
+                           failures {
+                             code: "general.error"
+                             url: "self#jumbf=/c2pa/urn:c2pa:1"
+                           }
+                         }
+                       }
+                  )pb")));
+}
+
+TEST_F(ManifestGraphTest, StandardManifestAccepted) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {})};
+  assertion_validator.SetOutputAssertions("/c2pa/urn:c2pa:1",
+                                          {empty_data_hash_assertion_});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, PopulatesMultiAssetHashUri) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {})};
+
+  Assertion multi_asset_hash_assertion = ParseTextProtoOrDie<Assertion>(
+      "label: 'c2pa.hash.multi-asset' multi_asset_hash {}");
+  Assertion part0_hash_assertion = ParseTextProtoOrDie<Assertion>(
+      "label: 'c2pa.hash.data.part' data_hash {}");
+  Assertion part1_hash_assertion = ParseTextProtoOrDie<Assertion>(
+      "label: 'c2pa.hash.data.part__1' data_hash {}");
+
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_, multi_asset_hash_assertion,
+       part0_hash_assertion, part1_hash_assertion});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "c2pa.hash.multi-asset"
+        multi_asset_hash {}
+      }
+      assertions {
+        label: "c2pa.hash.data.part"
+        data_hash {}
+      }
+      assertions {
+        label: "c2pa.hash.data.part__1"
+        data_hash {}
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+    multi_asset_hash_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.multi-asset"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, PopulatesHardBindingAndMultiAssetHashUris) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {})};
+
+  Assertion part1_data_hash_assertion = ParseTextProtoOrDie<Assertion>(
+      "label: 'c2pa.hash.data.part' data_hash {}");
+  Assertion part2_boxes_hash_assertion = ParseTextProtoOrDie<Assertion>(
+      "label: 'c2pa.hash.boxes.part' boxes_hash {}");
+
+  Assertion multi_asset_hash_assertion = ParseTextProtoOrDie<Assertion>(
+      "label: 'c2pa.hash.multi-asset' multi_asset_hash {}");
+
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {part1_data_hash_assertion, part2_boxes_hash_assertion,
+       multi_asset_hash_assertion, empty_data_hash_assertion_});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data.part"
+        data_hash {}
+      }
+      assertions {
+        label: "c2pa.hash.boxes.part"
+        boxes_hash {}
+      }
+      assertions {
+        label: "c2pa.hash.multi-asset"
+        multi_asset_hash {}
+      }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+    multi_asset_hash_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.multi-asset"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientStandardManifestAccepted) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:2", {}),
+  };
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "c2pa.ingredient.v3"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" }
+               claim_signature {
+                 url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature"
+               }
+             })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  // claimSignature.validated contains the "wrong" URL as it's added by the
+  // FakeClaimValidator.
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:2"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2"
+        }
+        successes {
+          code: "ingredient.claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" }
+          claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" }
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:1"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, UpdateManifestAccepted) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  auto standard_manifest =
+      CreateStandardManifest("urn:c2pa:1", {claim_signature_box_});
+  auto update_manifest = CreateUpdateManifest("urn:c2pa:2", {});
+  assertion_validator.SetOutputAssertions("/c2pa/urn:c2pa:1",
+                                          {empty_data_hash_assertion_});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2",
+      {ParseTextProtoOrDie<Assertion>(
+          R"pb(
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              relationship: "parentOf"
+              active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" }
+              claim_signature {
+                url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature"
+              }
+            })pb")});
+  std::vector<jumbf::SuperBox> manifests = {standard_manifest, update_manifest};
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:2"
+      is_update_manifest: true
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          relationship: "parentOf"
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" }
+          claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" }
+        }
+      }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2"
+        }
+        successes {
+          code: "ingredient.claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3"
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:1"
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest,
+       StandardManifestWithIngredientUpdateManifestAccepted) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:IngredientStandard", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:IngredientUpdate", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:ActiveStandard", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  auto standard_manifest = CreateStandardManifest("urn:c2pa:IngredientStandard",
+                                                  {claim_signature_box_});
+  auto update_manifest =
+      CreateUpdateManifest("urn:c2pa:IngredientUpdate", {claim_signature_box_});
+  auto active_manifest = CreateStandardManifest("urn:c2pa:ActiveStandard", {});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:IngredientUpdate",
+      {ParseTextProtoOrDie<Assertion>(
+          R"pb(
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              relationship: "parentOf"
+              active_manifest {
+                url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard"
+              }
+              claim_signature {
+                url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard/c2pa.signature"
+              }
+            })pb")});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:ActiveStandard",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "c2pa.ingredient.v3"
+             ingredient_v3 {
+               relationship: "parentOf"
+               active_manifest {
+                 url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate"
+               }
+               claim_signature {
+                 url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate/c2pa.signature"
+               }
+             })pb")});
+  std::vector<jumbf::SuperBox> manifests = {standard_manifest, update_manifest,
+                                            active_manifest};
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[2], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:ActiveStandard"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:ActiveStandard"
+        }
+        successes {
+          code: "ingredient.claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:ActiveStandard/c2pa.assertions/c2pa.ingredient.v3"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          relationship: "parentOf"
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate" }
+          claim_signature {
+            url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate/c2pa.signature"
+          }
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:IngredientUpdate"
+      is_update_manifest: true
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          relationship: "parentOf"
+          active_manifest {
+            url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard"
+          }
+          claim_signature {
+            url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard/c2pa.signature"
+          }
+        }
+      }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate"
+        }
+        successes {
+          code: "ingredient.claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:IngredientUpdate/c2pa.assertions/c2pa.ingredient.v3"
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:IngredientStandard"
+      claim { default_algorithm: "sha256" }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:IngredientStandard"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:ActiveStandard/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientManifestV3UrlIsInvalid) {
+  FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{},
+                                     /*successes=*/{});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1", {empty_data_hash_assertion_,
+                           ParseTextProtoOrDie<Assertion>(
+                               R"pb(label: "ingredient_v3"
+                                    ingredient_v3 {
+                                      active_manifest { url: "invalid_url" }
+                                    })pb")});
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "ingredient_v3"
+        ingredient_v3 { active_manifest { url: "invalid_url" } }
+      }
+      validation {
+        failures {
+          code: "ingredient.manifest.missing"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/ingredient_v3"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientV3ClaimSignatureHashMatch) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:2", {}),
+  };
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(absl::Substitute(
+           R"pb(
+             label: "c2pa.ingredient.v3"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" }
+               claim_signature {
+                 url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature"
+                 hash: "$0"
+                 algorithm: "sha256"
+               }
+             })pb",
+           absl::CEscape(GetHash("sha256", claim_signature_box_))))});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator,
+                      &claim_validator, &DefaultHashCheckerFactory(),
+                      &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT((*result)->active_manifest().validation().failures(), IsEmpty());
+  EXPECT_THAT(
+      (*result)->active_manifest().validation().successes(),
+      Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "ingredient.claimSignature.validated"
+        url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3"
+      )pb"))));
+  ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(1));
+  EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(),
+              IsEmpty());
+}
+
+TEST_F(ManifestGraphTest, IngredientV3ClaimSignatureHashMismatch) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:2", {}),
+  };
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(label: "ingredient_v3"
+                ingredient_v3 {
+                  active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:1" }
+                  claim_signature {
+                    url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature"
+                    hash: "wrong hash"
+                    algorithm: "sha256"
+                  }
+                })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator,
+                      &claim_validator, &DefaultHashCheckerFactory(),
+                      &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT((*result)->active_manifest().validation().failures(),
+              Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+                code: "ingredient.claimSignature.mismatch"
+                url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/ingredient_v3"
+              )pb"))));
+  EXPECT_THAT((*result)->ingredient_manifests(), IsEmpty());
+}
+
+TEST_F(ManifestGraphTest, IngredientManifestHashMatch) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:2", {}),
+  };
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(absl::Substitute(
+           R"pb(
+             label: "c2pa.ingredient.v3"
+             ingredient_v3 {
+               active_manifest {
+                 url: "self#jumbf=/c2pa/urn:c2pa:1"
+                 hash: "$0"
+                 algorithm: "sha256"
+               }
+             })pb",
+           absl::CEscape(GetHash("sha256", manifests[0]))))});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator,
+                      &claim_validator, &DefaultHashCheckerFactory(),
+                      &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT((*result)->active_manifest().validation().failures(), IsEmpty());
+  EXPECT_THAT(
+      (*result)->active_manifest().validation().successes(),
+      Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "ingredient.manifest.validated"
+        url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3"
+      )pb"))));
+  ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(1));
+  EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(),
+              IsEmpty());
+}
+
+TEST_F(ManifestGraphTest, IngredientManifestHashMismatch) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:2", {}),
+  };
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2", {empty_data_hash_assertion_,
+                           ParseTextProtoOrDie<Assertion>(
+                               R"pb(label: "c2pa.ingredient.v3"
+                                    ingredient_v3 {
+                                      active_manifest {
+                                        url: "self#jumbf=/c2pa/urn:c2pa:1"
+                                        hash: "wrong hash"
+                                        algorithm: "sha256"
+                                      }
+                                    })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[1], &uri_resolver, &assertion_validator,
+                      &claim_validator, &DefaultHashCheckerFactory(),
+                      &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(
+      (*result)->active_manifest().validation().failures(),
+      Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "ingredient.manifest.mismatch"
+        url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/c2pa.ingredient.v3"
+      )pb"))));
+  EXPECT_THAT((*result)->ingredient_manifests(), IsEmpty());
+}
+
+TEST_F(ManifestGraphTest, ManifestLegacyLabel) {
+  FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{},
+                                     /*successes=*/{});
+  claim_validator.SetClaimResult("urn:uuid:some_label", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box, active_manifest;
+  active_manifest.description.label = "urn:uuid:some_label";
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:uuid:some_label",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(ingredient_v3 {
+                  active_manifest {
+                    url: "self#jumbf=/c2pa/urn:uuid:some_label"
+                  }
+                })pb")});
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(
+      **result,
+      EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+        active_manifest {
+          label: "urn:uuid:some_label"
+          validation {
+            failures {
+              code: "com.google.unsupportedSpecVersion"
+              url: "self#jumbf=/c2pa/urn:uuid:some_label"
+              explanation: "deprecated manifest label format"
+            }
+          }
+        }
+      )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientManifestUrlValidButManifestMissing) {
+  FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{},
+                                     /*successes=*/{});
+  claim_validator.SetClaimResult("urn:c2pa:some_label", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  jumbf::SuperBox active_manifest =
+      CreateStandardManifest("urn:c2pa:some_label", {});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:some_label",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(label: "ingredient_v3"
+                ingredient_v3 {
+                  active_manifest {
+                    url: "self#jumbf=/c2pa/urn:c2pa:some_label"
+                  }
+                })pb")});
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:some_label"
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "ingredient_v3"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:some_label" }
+        }
+      }
+      validation {
+        failures {
+          code: "ingredient.manifest.missing"
+          url: "self#jumbf=/c2pa/urn:c2pa:some_label/c2pa.assertions/ingredient_v3"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:some_label/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientManifestV3SelfRedacted) {
+  FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{},
+                                     /*successes=*/{});
+  Claim claim = DefaultClaim();
+  claim.add_redacted_assertions("self#jumbf=self_redacted");
+  claim_validator.SetClaimResult("urn:c2pa:1", claim);
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {});
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(
+      **result,
+      EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+        active_manifest {
+          label: "urn:c2pa:1"
+          claim {
+            redacted_assertions: "self#jumbf=self_redacted"
+            default_algorithm: "sha256"
+          }
+          validation {
+            failures {
+              code: "assertion.selfRedacted"
+              url: "self#jumbf=/c2pa/urn:c2pa:1"
+            }
+          }
+        }
+      )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientManifestV3InvalidRedactedAssertionUri) {
+  FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{},
+                                     /*successes=*/{});
+  Claim claim = DefaultClaim();
+  claim.add_redacted_assertions("invalid_uri");
+  claim_validator.SetClaimResult("urn:c2pa:1", claim);
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {});
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(
+      **result,
+      EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+        active_manifest {
+          label: "urn:c2pa:1"
+          claim {
+            redacted_assertions: "invalid_uri"
+            default_algorithm: "sha256"
+          }
+          validation {
+            failures {
+              code: "assertion.missing"
+              url: "self#jumbf=/c2pa/urn:c2pa:1"
+            }
+          }
+        }
+      )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientManifestV3InvalidRedactedAssertionPath) {
+  FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{},
+                                     /*successes=*/{});
+  Claim claim = DefaultClaim();
+  claim.add_redacted_assertions("self#jumbf=/invalid_path");
+  claim_validator.SetClaimResult("urn:c2pa:1", claim);
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  jumbf::SuperBox active_manifest = CreateStandardManifest("urn:c2pa:1", {});
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&root_box);
+  ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(
+      **result,
+      EqualsProto(ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+        active_manifest {
+          label: "urn:c2pa:1"
+          claim {
+            redacted_assertions: "self#jumbf=/invalid_path"
+            default_algorithm: "sha256"
+          }
+          validation {
+            failures {
+              code: "assertion.missing"
+              url: "self#jumbf=/c2pa/urn:c2pa:1"
+            }
+          }
+        }
+      )pb")));
+}
+
+TEST_F(ManifestGraphTest, RedactedAssertionsProvidedToAssertionValidator) {
+  // Manifest graph:
+  //   urn:c2pa:4 (active) has ingredients urn:c2pa:2, urn:c2pa:3
+  //   unn:c2pa:2 has ingredient urn:c2pa:1
+  //   urn:c2pa:3 has ingredient urn:c2pa:1
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  Claim claim1 = DefaultClaim();
+  Claim claim2 = ParseTextProtoOrDie<Claim>(R"pb(
+    default_algorithm: "sha256"
+    redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim2"
+  )pb");
+  Claim claim3 = ParseTextProtoOrDie<Claim>(R"pb(
+    default_algorithm: "sha256"
+    redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim3"
+  )pb");
+  Claim claim4 = ParseTextProtoOrDie<Claim>(R"pb(
+    default_algorithm: "sha256"
+    redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim4"
+    redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:2/claim2_assertion_redacted_by_claim4"
+    redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:3/claim3_assertion_redacted_by_claim4"
+  )pb");
+  claim_validator.SetClaimResult("urn:c2pa:1", claim1);
+  claim_validator.SetClaimResult("urn:c2pa:2", claim2);
+  claim_validator.SetClaimResult("urn:c2pa:3", claim3);
+  claim_validator.SetClaimResult("urn:c2pa:4", claim4);
+  MockAssertionValidator assertion_validator;
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(CreateStandardManifest("urn:c2pa:1", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:2", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:3", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:4", {}));
+  EXPECT_CALL(assertion_validator,
+              ValidateClaimAssertions(
+                  _, _, Property(&Manifest::label, "urn:c2pa:1"), _))
+      .WillOnce(
+          [&](const jumbf::UriResolver&,
+              const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+              Manifest& manifest, ValidationTracker&) {
+            EXPECT_THAT(
+                redacted_assertion_paths,
+                // Note that the assertion redacted by claim3 does not appear
+                // here, because we haven't reached claim3 yet in the DFS
+                // when we process claim1. See discussion in
+                // https://github.com/c2pa-org/specs-core/issues/1540.
+                UnorderedElementsAre(
+                    "/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim4",
+                    "/c2pa/urn:c2pa:1/claim1_assertion_redacted_by_claim2"));
+          });
+  EXPECT_CALL(assertion_validator,
+              ValidateClaimAssertions(
+                  _, _, Property(&Manifest::label, "urn:c2pa:2"), _))
+      .WillOnce(
+          [&](const jumbf::UriResolver&,
+              const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+              Manifest& manifest, ValidationTracker&) {
+            EXPECT_THAT(
+                redacted_assertion_paths,
+                UnorderedElementsAre(
+                    "/c2pa/urn:c2pa:2/claim2_assertion_redacted_by_claim4"));
+            auto* active_manifest = manifest.add_assertions()
+                                        ->mutable_ingredient_v3()
+                                        ->mutable_active_manifest();
+            active_manifest->set_url("self#jumbf=/c2pa/urn:c2pa:1");
+          });
+  EXPECT_CALL(assertion_validator,
+              ValidateClaimAssertions(
+                  _, _, Property(&Manifest::label, "urn:c2pa:3"), _))
+      .WillOnce(
+          [&](const jumbf::UriResolver&,
+              const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+              Manifest& manifest, ValidationTracker&) {
+            EXPECT_THAT(
+                redacted_assertion_paths,
+                UnorderedElementsAre(
+                    "/c2pa/urn:c2pa:3/claim3_assertion_redacted_by_claim4"));
+            auto* active_manifest = manifest.add_assertions()
+                                        ->mutable_ingredient_v3()
+                                        ->mutable_active_manifest();
+            active_manifest->set_url("self#jumbf=/c2pa/urn:c2pa:1");
+          });
+  EXPECT_CALL(assertion_validator,
+              ValidateClaimAssertions(
+                  _, _, Property(&Manifest::label, "urn:c2pa:4"), _))
+      .WillOnce(
+          [&](const jumbf::UriResolver&,
+              const absl::flat_hash_set<std::string>& redacted_assertion_paths,
+              Manifest& manifest, ValidationTracker&) {
+            EXPECT_THAT(redacted_assertion_paths, IsEmpty());
+            auto* active_manifest1 = manifest.add_assertions()
+                                         ->mutable_ingredient_v3()
+                                         ->mutable_active_manifest();
+            active_manifest1->set_url("self#jumbf=/c2pa/urn:c2pa:2");
+            auto* active_manifest2 = manifest.add_assertions()
+                                         ->mutable_ingredient_v3()
+                                         ->mutable_active_manifest();
+            active_manifest2->set_url("self#jumbf=/c2pa/urn:c2pa:3");
+          });
+
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests.back(), &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+}
+
+TEST_F(ManifestGraphTest, IngredientNesting) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:5", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:6", DefaultClaim());
+
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests = {
+      CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:2", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:3", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:4", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:5", {claim_signature_box_}),
+      CreateStandardManifest("urn:c2pa:6", {claim_signature_box_}),
+  };
+  for (int i = 2; i <= 6; ++i) {
+    assertion_validator.SetOutputAssertions(
+        absl::StrCat("/c2pa/urn:c2pa:", i),
+        {empty_data_hash_assertion_,
+         ParseTextProtoOrDie<Assertion>(absl::Substitute(
+             R"pb(
+               label: "c2pa.ingredient.v3"
+               ingredient_v3 {
+                 active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:$0" }
+                 claim_signature {
+                   url: "self#jumbf=/c2pa/urn:c2pa:$0/c2pa.signature"
+                 }
+               })pb",
+             i - 1))});
+  }
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+
+  ManifestGraph graph(&manifests[5], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT((*result)->active_manifest().validation().failures(), IsEmpty());
+  ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(5));
+  EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(),
+              IsEmpty());
+  EXPECT_THAT((*result)->ingredient_manifests(1).validation().failures(),
+              IsEmpty());
+  EXPECT_THAT((*result)->ingredient_manifests(2).validation().failures(),
+              IsEmpty());
+  EXPECT_THAT((*result)->ingredient_manifests(3).validation().failures(),
+              IsEmpty());
+  EXPECT_THAT((*result)->ingredient_manifests(4).validation().failures(),
+              IsEmpty());
+}
+
+TEST_F(ManifestGraphTest, GraphContainsLoop) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:active_manifest", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:ingredient_manifest",
+                                 DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  jumbf::SuperBox active_manifest = CreateStandardManifest(
+      "urn:c2pa:active_manifest", {claim_signature_box_});
+  jumbf::SuperBox ingredient_manifest = CreateStandardManifest(
+      "urn:c2pa:ingredient_manifest", {claim_signature_box_});
+  jumbf::SuperBox manifest_store =
+      CreateManifestStore({ingredient_manifest, active_manifest});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:active_manifest",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(ingredient_v3 {
+                  active_manifest {
+                    url: "self#jumbf=/c2pa/urn:c2pa:ingredient_manifest"
+                  }
+                  claim_signature {
+                    url: "self#jumbf=/c2pa/urn:c2pa:ingredient_manifest/c2pa.signature"
+                  }
+                })pb")});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:ingredient_manifest",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(ingredient_v3 {
+                  active_manifest {
+                    url: "self#jumbf=/c2pa/urn:c2pa:active_manifest"
+                  }
+                  claim_signature {
+                    url: "self#jumbf=/c2pa/urn:c2pa:active_manifest/c2pa.signature"
+                  }
+                })pb")});
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&active_manifest, &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT((*result)->active_manifest().validation().failures(), IsEmpty());
+  ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(1));
+  EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(),
+              IsEmpty());
+}
+
+TEST_F(ManifestGraphTest, IngredientV3WithInvalidClaimSignatureUri) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(CreateStandardManifest("urn:c2pa:1", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:2", {}));
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(label: "ingredient_v3"
+                ingredient_v3 {
+                  active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" }
+                  claim_signature { url: "invalid_uri" }
+                })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+        failures {
+          code: "ingredient.claimSignature.missing"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/ingredient_v3"
+          explanation: "self#jumbf= prefix required"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "ingredient_v3"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" }
+          claim_signature { url: "invalid_uri" }
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientV3WithoutManifest) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(CreateStandardManifest("urn:c2pa:1", {}));
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(label: "ingredient_v3"
+                ingredient_v3 {
+                  relationship: "componentOf"
+                  claim_signature {
+                    url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature"
+                  }
+                })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "ingredient_v3"
+        ingredient_v3 {
+          relationship: "componentOf"
+          claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" }
+        }
+      }
+      validation {
+        informationals {
+          code: "ingredient.unknownProvenance"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/ingredient_v3"
+        }
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientV3InputToWithoutManifest) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(CreateStandardManifest("urn:c2pa:1", {}));
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(ingredient_v3 {
+                  relationship: "inputTo"
+                  claim_signature {
+                    url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature"
+                  }
+                })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        ingredient_v3 {
+          relationship: "inputTo"
+          claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" }
+        }
+      }
+      validation {
+        # No ingredient.unknownProvenance informational for inputTo ingredient.
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientV3WithValidClaimSignatureUri) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(
+      CreateStandardManifest("urn:c2pa:1", {claim_signature_box_}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:2", {}));
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" }
+               claim_signature {
+                 url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature"
+               }
+             })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "assert"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" }
+          claim_signature { url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.signature" }
+        }
+      }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+        successes {
+          code: "ingredient.claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert"
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:2"
+      claim { default_algorithm: "sha256" }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientV3WithMultipleParents) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(CreateStandardManifest("urn:c2pa:1", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:2", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:3", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:4", {}));
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_2"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" }
+             })pb"),
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_3"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" }
+             })pb")});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2",
+      {ParseTextProtoOrDie<Assertion>(
+          R"pb(
+            label: "assert_for_4"
+            ingredient_v3 {
+              active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" }
+            })pb")});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:3",
+      {ParseTextProtoOrDie<Assertion>(
+          R"pb(
+            label: "assert_for_4"
+            ingredient_v3 {
+              active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" }
+            })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_2"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_3"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "assert_for_2"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } }
+      }
+      assertions {
+        label: "assert_for_3"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:2"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/assert_for_4"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "assert_for_4"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:4"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:4"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:3"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:3"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:3/c2pa.assertions/assert_for_4"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "assert_for_4"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientV3AllButOneIngredientManifestIsValid) {
+  FakeClaimValidator claim_validator(/*failures=*/{}, /*informationals=*/{},
+                                     /*successes=*/{});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimSuccesses(
+      "urn:c2pa:1", {SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  claim_validator.SetClaimSuccesses(
+      "urn:c2pa:2", {SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim());
+  claim_validator.SetClaimSuccesses(
+      "urn:c2pa:3", {SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimInformationals(
+      "urn:c2pa:3", {InformationalStatusCode::kIngredientUnknownProvenance});
+  claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim());
+  claim_validator.SetClaimFailures(
+      "urn:c2pa:4", {FailureStatusCode::kClaimSignatureMismatch});
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(CreateStandardManifest("urn:c2pa:1", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:2", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:3", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:4", {}));
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_2"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" }
+             })pb"),
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_3"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" }
+             })pb"),
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_4"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" }
+             })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "assert_for_2"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } }
+      }
+      assertions {
+        label: "assert_for_3"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } }
+      }
+      assertions {
+        label: "assert_for_4"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } }
+      }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_2"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_3"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_4"
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:2"
+      claim { default_algorithm: "sha256" }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2"
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:3"
+      claim { default_algorithm: "sha256" }
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:3"
+        }
+        informationals {
+          code: "ingredient.unknownProvenance"
+          url: "self#jumbf=/c2pa/urn:c2pa:3"
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:4"
+      claim { default_algorithm: "sha256" }
+      validation {
+        failures {
+          code: "claimSignature.mismatch"
+          url: "self#jumbf=/c2pa/urn:c2pa:4"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientV3WithRedactions) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  Claim claim1 = DefaultClaim();
+  Claim claim2 = DefaultClaim();
+  claim1.add_redacted_assertions("self#jumbf=/c2pa/urn:c2pa:2/some_assertion");
+  claim2.add_created_assertions()->set_url("self#jumbf=some_assertion");
+  claim_validator.SetClaimResult("urn:c2pa:1", claim1);
+  claim_validator.SetClaimResult("urn:c2pa:2", claim2);
+  claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(CreateStandardManifest("urn:c2pa:1", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:2", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:3", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:4", {}));
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_2"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" }
+             })pb"),
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_3"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" }
+             })pb")});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2",
+      {ParseTextProtoOrDie<Assertion>(
+          R"pb(
+            label: "assert_for_4"
+            ingredient_v3 {
+              active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" }
+            })pb")});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:3",
+      {ParseTextProtoOrDie<Assertion>(
+          R"pb(
+            label: "assert_for_4"
+            ingredient_v3 {
+              active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" }
+            })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(**result, EqualsProto(ParseTextProtoOrDie<
+                                    PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "urn:c2pa:1"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_2"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/assert_for_3"
+        }
+      }
+      claim {
+        redacted_assertions: "self#jumbf=/c2pa/urn:c2pa:2/some_assertion"
+        default_algorithm: "sha256"
+      }
+      assertions {
+        label: "c2pa.hash.data"
+        data_hash {}
+      }
+      assertions {
+        label: "assert_for_2"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" } }
+      }
+      assertions {
+        label: "assert_for_3"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" } }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:2"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:2/c2pa.assertions/assert_for_4"
+        }
+      }
+      claim {
+        created_assertions { url: "self#jumbf=some_assertion" }
+        default_algorithm: "sha256"
+      }
+      assertions {
+        label: "assert_for_4"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:4"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:4"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:3"
+      validation {
+        successes {
+          code: "claimSignature.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:3"
+        }
+        successes {
+          code: "ingredient.manifest.validated"
+          url: "self#jumbf=/c2pa/urn:c2pa:3/c2pa.assertions/assert_for_4"
+        }
+      }
+      claim { default_algorithm: "sha256" }
+      assertions {
+        label: "assert_for_4"
+        ingredient_v3 { active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" } }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:1/c2pa.assertions/c2pa.hash.data"
+  )pb")));
+}
+
+TEST_F(ManifestGraphTest, IngredientAssertionsWithFailures) {
+  FakeClaimValidator claim_validator(
+      /*failures=*/{}, /*informationals=*/{},
+      /*successes=*/{SuccessStatusCode::kClaimSignatureValidated});
+  claim_validator.SetClaimResult("urn:c2pa:1", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:2", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:3", DefaultClaim());
+  claim_validator.SetClaimResult("urn:c2pa:4", DefaultClaim());
+  FakeAssertionValidator assertion_validator(/*failures=*/{}, /*successes=*/{});
+  jumbf::SuperBox root_box;
+  std::vector<jumbf::SuperBox> manifests;
+  manifests.push_back(CreateStandardManifest("urn:c2pa:1", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:2", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:3", {}));
+  manifests.push_back(CreateStandardManifest("urn:c2pa:4", {}));
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:1",
+      {empty_data_hash_assertion_,
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_2"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:2" }
+               validation_results {
+                 active_manifest {
+                   failures { code: "assertion.dataHash.mismatch" url: "blah2" }
+                 }
+               }
+             })pb"),
+       ParseTextProtoOrDie<Assertion>(
+           R"pb(
+             label: "assert_for_3"
+             ingredient_v3 {
+               active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:3" }
+             })pb")});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:2",
+      {ParseTextProtoOrDie<Assertion>(
+          R"pb(
+            label: "assert_for_4"
+            ingredient_v3 {
+              active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" }
+              validation_results {
+                active_manifest {
+                  failures { code: "assertion.dataHash.mismatch" url: "blah4" }
+                }
+              }
+            })pb")});
+  assertion_validator.SetOutputAssertions(
+      "/c2pa/urn:c2pa:3",
+      {ParseTextProtoOrDie<Assertion>(
+          R"pb(
+            label: "assert_for_4"
+            ingredient_v3 {
+              active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:4" }
+              validation_results {
+                active_manifest {
+                  failures { code: "assertion.dataHash.malformed" url: "blah4" }
+                }
+              }
+            })pb")});
+  jumbf::SuperBox manifest_store = CreateManifestStore(manifests);
+  jumbf::UriResolver uri_resolver =
+      jumbf::UriResolver::WithSingleRootChild(&manifest_store);
+  ManifestGraph graph(&manifests[0], &uri_resolver, &assertion_validator,
+                      &claim_validator, &noop_hash_checker_factory_, &options_);
+  auto result = graph.Validate();
+  ABSL_ASSERT_OK(result);
+  ASSERT_THAT((*result)->ingredient_manifests(), SizeIs(3));
+  EXPECT_THAT((*result)->ingredient_manifests(0).label(), "urn:c2pa:2");
+  EXPECT_THAT((*result)->ingredient_manifests(0).validation().failures(),
+              Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+                code: "assertion.dataHash.mismatch"
+                url: "blah2"
+                explanation: "from original validator"
+              )pb"))));
+  EXPECT_THAT((*result)->ingredient_manifests(1).label(), "urn:c2pa:4");
+  EXPECT_THAT((*result)->ingredient_manifests(1).validation().failures(),
+              Contains(EqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+                code: "assertion.dataHash.mismatch"
+                url: "blah4"
+                explanation: "from original validator"
+              )pb"))));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/validator/manifest_store_validator.h b/validator/manifest_store_validator.h
new file mode 100644
index 0000000..9608614
--- /dev/null
+++ b/validator/manifest_store_validator.h
@@ -0,0 +1,45 @@
+// 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_VALIDATOR_MANIFEST_STORE_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/validation_result.pb.h"
+
+namespace credentio {
+
+class ManifestStoreValidator {
+ public:
+  virtual ~ManifestStoreValidator() = default;
+
+  // Validates just the manifest store and returns the result and
+  // information about the hard binding. Returns an error if the validation
+  // could not be performed (eg. due to an internal error).
+  //
+  // NOTE: This method does NOT validate the hard binding. That must be done
+  // separately. Without that being processed, the result cannot be
+  // considered complete.
+  virtual absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+  Validate(absl::string_view manifest_store) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_H_
diff --git a/validator/manifest_store_validator_impl.cc b/validator/manifest_store_validator_impl.cc
new file mode 100644
index 0000000..5fd6d9f
--- /dev/null
+++ b/validator/manifest_store_validator_impl.cc
@@ -0,0 +1,174 @@
+// 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 "validator/manifest_store_validator_impl.h"
+
+#include <memory>
+#include <optional>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "assertion/validator.h"
+#include "claim/validator.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "cose/verifier.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "jumbf/box.h"
+#include "jumbf/parse.h"
+#include "jumbf/uri.h"
+#include "proto/assertion.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/validation_result.pb.h"
+#include "utils/dual_status_tracker.h"
+#include "uuid/uuid.h"
+#include "validator/graph.h"
+#include "validator/result.h"
+#include "validator/validator_metrics.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+
+namespace {
+
+using ::jumbf::SuperBox;
+
+bool IsManifestStore(const SuperBox& superbox) {
+  return superbox.description.label == kManifestStoreLabel &&
+         superbox.description.type_uuid == kManifestStoreUuid;
+}
+
+bool IsManifest(const SuperBox& superbox) {
+  const auto& uuid = superbox.description.type_uuid;
+  return uuid == kStandardManifestUuid || uuid == kUpdateManifestUuid ||
+         uuid == kCompressedManifestUuid || uuid == kTimestampManifestUuid;
+}
+
+absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+ValidateManifestStore(absl::string_view manifest_store,
+                      const ValidatorOptions& options,
+                      const AssertionValidator& assertion_validator,
+                      const ClaimValidator& claim_validator,
+                      const HashCheckerFactory& hash_checker_factory,
+                      ValidatorMetrics* absl_nullable metrics) {
+  // Obtain the superbox proto from the serialized manifest.
+  // Any error from parsing the JUMBF boxes is considered "No Manifest Found"
+  // A recursion limit of 3 should suffice, but we'll allow for a bit more in
+  // case of future changes.
+  absl::StatusOr<SuperBox> ms_superbox_or =
+      jumbf::ConsumeSuperBox(&manifest_store, /*recursion_limit=*/9);
+  if (!ms_superbox_or.ok()) {
+    return absl::NotFoundError(ms_superbox_or.status().message());
+  }
+  SuperBox ms_superbox = *std::move(ms_superbox_or);
+
+  auto uri_resolver = jumbf::UriResolver::WithSingleRootChild(&ms_superbox);
+  if (!IsManifestStore(ms_superbox)) {
+    return absl::NotFoundError(
+        "JUMBF SuperBox does not contain a manifest store");
+  }
+
+  // Fetch the active manifest from the manifest store.
+  // The last C2PA Manifest superbox in the C2PA Manifest Store superbox shall
+  // be considered the active manifest.
+  std::optional<SuperBox> active_manifest;
+  for (auto it = ms_superbox.contents.rbegin();
+       it != ms_superbox.contents.rend(); ++it) {
+    if (it->Holds<SuperBox>() && IsManifest(it->Get<SuperBox>())) {
+      active_manifest = it->Get<SuperBox>();
+      break;
+    }
+  }
+  if (!active_manifest.has_value()) {
+    return absl::NotFoundError("No active manifest found in manifest store");
+  }
+
+  ManifestGraph graph(&*active_manifest, &uri_resolver, &assertion_validator,
+                      &claim_validator, &hash_checker_factory, &options);
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> result =
+      graph.Validate();
+
+  if (!result.ok()) {
+    // Don't suppress the internal error.
+    if (result.status().code() == absl::StatusCode::kInternal) {
+      return result;
+    }
+    return absl::NotFoundError(result.status().message());
+  }
+
+  return result;
+}
+
+}  // namespace
+
+ManifestStoreValidatorImpl::ManifestStoreValidatorImpl(ValidatorOptions options)
+    : cose_verifier_(CreateCoseVerifier({
+          .crypto_read_handler = std::move(options.crypto_read_handler),
+          .clock = options.clock,
+      })),
+      options_(std::move(options)),
+      claim_validator_(cose_verifier_.get()),
+      assertion_validator_({.skip_actions_assertion_validation_for_test =
+                                options_.accept_legacy_manifest_for_test}),
+      hash_checker_factory_(DefaultHashCheckerFactory()) {}
+
+absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+ManifestStoreValidatorImpl::Validate(absl::string_view manifest_store) const {
+  ABSL_ASSIGN_OR_RETURN(
+      auto partial_validation_result,
+      ValidateManifestStore(manifest_store, options_, assertion_validator_,
+                            claim_validator_, hash_checker_factory_,
+                            options_.metrics));
+
+  if (options_.spec_version >= SpecVersion::kC2pa_2_4) {
+    partial_validation_result->set_spec_version(
+        SpecVersionToString(options_.spec_version));
+    if (!options_.trust_list_uri.empty()) {
+      partial_validation_result->set_trust_list_uri(options_.trust_list_uri);
+    }
+  }
+
+  if (options_.metrics != nullptr) {
+    options_.metrics->RecordPartialValidationResult(*partial_validation_result);
+  }
+
+  if (partial_validation_result->has_hard_binding_uri()) {
+    // The manifest store indicates that a hard binding assertion is present.
+    const Assertion* absl_nullable assertion =
+        GetAssertion(partial_validation_result.get(),
+                     partial_validation_result->hard_binding_uri());
+    if (assertion == nullptr) {
+      // The assertion cannot be found, this means it was redacted for being a
+      // deprecated assertion.
+      ABSL_ASSIGN_OR_RETURN(auto tracker,
+                            DualStatusTracker::FromPartialValidationResult(
+                                partial_validation_result.get()));
+      tracker->RecordFailure(
+          FailureStatusCode::kClaimHardBindingsMissing,
+          {.explanation = "The active hard binding assertion was deprecated."});
+      partial_validation_result->clear_hard_binding_uri();
+    }
+  }
+
+  return partial_validation_result;
+}
+
+}  // namespace credentio
diff --git a/validator/manifest_store_validator_impl.h b/validator/manifest_store_validator_impl.h
new file mode 100644
index 0000000..a090447
--- /dev/null
+++ b/validator/manifest_store_validator_impl.h
@@ -0,0 +1,51 @@
+// 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_VALIDATOR_MANIFEST_STORE_VALIDATOR_IMPL_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_IMPL_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "assertion/validator.h"
+#include "claim/validator.h"
+#include "cose/verifier.h"
+#include "crypto/hash.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/validation_result.pb.h"
+#include "validator/manifest_store_validator.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+
+class ManifestStoreValidatorImpl : public ManifestStoreValidator {
+ public:
+  explicit ManifestStoreValidatorImpl(ValidatorOptions options);
+
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> Validate(
+      absl::string_view manifest_store) const override;
+
+ private:
+  std::unique_ptr<CoseVerifier> cose_verifier_;
+  const ValidatorOptions options_;
+  ClaimValidatorImpl claim_validator_;
+  AssertionValidator assertion_validator_;
+  const HashCheckerFactory& hash_checker_factory_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_STORE_VALIDATOR_IMPL_H_
diff --git a/validator/result.cc b/validator/result.cc
new file mode 100644
index 0000000..cd486f0
--- /dev/null
+++ b/validator/result.cc
@@ -0,0 +1,101 @@
+// 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 "validator/result.h"
+
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/strings/str_split.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/strip.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+
+namespace credentio {
+namespace {
+
+const Manifest* GetManifest(const PartialValidationResultProto* result,
+                            absl::string_view label) {
+  if (result->active_manifest().label() == label) {
+    return &result->active_manifest();
+  }
+  for (const auto& ingredient_manifest : result->ingredient_manifests()) {
+    if (ingredient_manifest.label() == label) {
+      return &ingredient_manifest;
+    }
+  }
+  return nullptr;
+}
+
+Manifest* GetMutableManifest(PartialValidationResultProto* result,
+                             absl::string_view label) {
+  if (result->active_manifest().label() == label) {
+    return result->mutable_active_manifest();
+  }
+  for (auto& ingredient_manifest : *result->mutable_ingredient_manifests()) {
+    if (ingredient_manifest.label() == label) {
+      return &ingredient_manifest;
+    }
+  }
+  return nullptr;
+}
+
+}  // namespace
+
+const Assertion* absl_nullable GetAssertion(
+    const PartialValidationResultProto* result,
+    absl::string_view assertion_uri) {
+  if (!absl::ConsumePrefix(&assertion_uri, "self#jumbf=/c2pa/")) {
+    return nullptr;
+  }
+  std::vector<absl::string_view> labels = absl::StrSplit(assertion_uri, '/');
+  if (labels.size() != 3) {
+    return nullptr;
+  }
+  if (labels[1] != "c2pa.assertions") {
+    return nullptr;
+  }
+  absl::string_view manifest_label = labels[0];
+  absl::string_view assertion_label = labels[2];
+  const Manifest* manifest = GetManifest(result, manifest_label);
+  if (manifest == nullptr) {
+    return nullptr;
+  }
+  for (const auto& assertion : manifest->assertions()) {
+    if (assertion.label() == assertion_label) {
+      return &assertion;
+    }
+  }
+  return nullptr;
+}
+
+Manifest* absl_nullable GetMutableManifestForAssertion(
+    PartialValidationResultProto* result, absl::string_view assertion_uri) {
+  if (!absl::ConsumePrefix(&assertion_uri, "self#jumbf=/c2pa/")) {
+    return nullptr;
+  }
+  std::vector<absl::string_view> labels = absl::StrSplit(assertion_uri, '/');
+  if (labels.size() != 3) {
+    return nullptr;
+  }
+  if (labels[1] != "c2pa.assertions") {
+    return nullptr;
+  }
+  absl::string_view manifest_label = labels[0];
+  return GetMutableManifest(result, manifest_label);
+}
+
+}  // namespace credentio
diff --git a/validator/result.h b/validator/result.h
new file mode 100644
index 0000000..6af0a57
--- /dev/null
+++ b/validator/result.h
@@ -0,0 +1,69 @@
+// 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_VALIDATOR_RESULT_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_RESULT_H_
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/strings/string_view.h"
+#include "proto/assertion.pb.h"
+#include "proto/validation_result.pb.h"
+
+namespace credentio {
+
+// Result of validating a C2PA asset.
+//
+// For now, this is just a wrapper for `ValidationResultProto`.
+class ValidationResult {
+ public:
+  explicit ValidationResult(std::unique_ptr<ValidationResultProto> proto,
+                            std::string manifest_store_bytes = "")
+      : proto_(std::move(proto)),
+        manifest_store_bytes_(std::move(manifest_store_bytes)) {}
+
+  const ValidationResultProto& proto() const { return *proto_; }
+  ValidationResultProto& mutable_proto() { return *proto_; }
+
+  std::unique_ptr<ValidationResultProto> release_proto() {
+    return std::move(proto_);
+  }
+
+  absl::string_view manifest_store_bytes() const {
+    return manifest_store_bytes_;
+  }
+
+ private:
+  std::unique_ptr<ValidationResultProto> proto_;
+  std::string manifest_store_bytes_;
+};
+
+// Returns a pointer to the assertion with the given URI, or nullptr if not
+// found.
+const Assertion* absl_nullable GetAssertion(
+    const PartialValidationResultProto* result,
+    absl::string_view assertion_uri);
+
+// Returns a pointer to the Manifest containing the assertion with the
+// given URI, or nullptr if not found.
+Manifest* absl_nullable GetMutableManifestForAssertion(
+    PartialValidationResultProto* result, absl::string_view assertion_uri);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_RESULT_H_
diff --git a/validator/result_test.cc b/validator/result_test.cc
new file mode 100644
index 0000000..37b49b1
--- /dev/null
+++ b/validator/result_test.cc
@@ -0,0 +1,240 @@
+// 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 "validator/result.h"
+
+#include <memory>
+#include <utility>
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/assertion.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::IsNull;
+using ::testing::Pointee;
+
+TEST(ValidationResultTest, ToProto) {
+  auto proto = std::make_unique<ValidationResultProto>();
+  proto->mutable_active_manifest()->set_label("test");
+  ValidationResult result(std::move(proto));
+  ValidationResultProto expected;
+  expected.mutable_active_manifest()->set_label("test");
+  EXPECT_THAT(result.proto(), EqualsProto(expected));
+}
+
+TEST(GetAssertionTest, ReturnsAssertionFromActiveManifest) {
+  PartialValidationResultProto result;
+  result.mutable_active_manifest()->set_label("active_manifest");
+  result.mutable_active_manifest()->add_assertions()->set_label("assertion1");
+  result.mutable_active_manifest()->add_assertions()->set_label("assertion2");
+  result.mutable_active_manifest()->add_assertions()->set_label("assertion3");
+  EXPECT_THAT(
+      GetAssertion(
+          &result,
+          "self#jumbf=/c2pa/active_manifest/c2pa.assertions/assertion2"),
+      Pointee(
+          EqualsProto(ParseTextProtoOrDie<Assertion>("label: 'assertion2'"))));
+}
+
+TEST(GetAssertionTest, ReturnsAssertionFromIngredientManifest) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "active_manifest" }
+    ingredient_manifests {
+      label: "ingredient_manifest1"
+      assertions {
+        label: "assertion1"
+        actions {}
+      }
+    }
+    ingredient_manifests {
+      label: "ingredient_manifest2"
+      assertions {
+        label: "assertion1"
+        data_hash {}
+      }
+    }
+  )pb");
+  EXPECT_THAT(
+      GetAssertion(
+          &result,
+          "self#jumbf=/c2pa/ingredient_manifest2/c2pa.assertions/assertion1"),
+      Pointee(EqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+        label: "assertion1"
+        data_hash {}
+      )pb"))));
+}
+
+TEST(GetAssertionTest, InvalidUriPrefix) {
+  PartialValidationResultProto result;
+  EXPECT_THAT(GetAssertion(&result, "invalid_prefix"), IsNull());
+}
+
+TEST(GetAssertionTest, InvalidUriPathLength) {
+  PartialValidationResultProto result;
+  EXPECT_THAT(
+      GetAssertion(&result, "self#jumbf=/c2pa/active_manifest/assertion1"),
+      IsNull());
+}
+
+TEST(GetAssertionTest, InvalidUriAssertionsPart) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "active_manifest"
+      assertions { label: "assertion1" }
+    }
+  )pb");
+  EXPECT_THAT(
+      GetAssertion(&result,
+                   "self#jumbf=/c2pa/active_manifest/invalid_part/assertion1"),
+      IsNull());
+}
+
+TEST(GetAssertionTest, ManifestLabelNotFound) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "active_manifest"
+      assertions { label: "assertion1" }
+    }
+    ingredient_manifests {
+      label: "ingredient_manifest1"
+      assertions {
+        label: "assertion1"
+        actions {}
+      }
+    }
+  )pb");
+  EXPECT_THAT(GetAssertion(
+                  &result,
+                  "self#jumbf=/c2pa/other_manifest/c2pa.assertions/assertion1"),
+              IsNull());
+}
+
+TEST(GetAssertionTest, AssertionLabelNotFound) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "active_manifest"
+      assertions { label: "assertion1" }
+    }
+    ingredient_manifests {
+      label: "ingredient_manifest1"
+      assertions {
+        label: "assertion1"
+        actions {}
+      }
+    }
+  )pb");
+  EXPECT_THAT(
+      GetAssertion(
+          &result,
+          "self#jumbf=/c2pa/active_manifest/c2pa.assertions/assertion2"),
+      IsNull());
+}
+
+TEST(GetMutableManifestForAssertionTest, ReturnsManifestFromActiveManifest) {
+  PartialValidationResultProto result;
+  result.mutable_active_manifest()->set_label("active_manifest");
+  result.mutable_active_manifest()->add_assertions()->set_label("assertion1");
+  result.mutable_active_manifest()->add_assertions()->set_label("assertion2");
+  result.mutable_active_manifest()->add_assertions()->set_label("assertion3");
+  EXPECT_THAT(
+      GetMutableManifestForAssertion(
+          &result,
+          "self#jumbf=/c2pa/active_manifest/c2pa.assertions/assertion2"),
+      Pointee(EqualsProto(ParseTextProtoOrDie<Manifest>(R"pb(
+        label: "active_manifest"
+        assertions { label: "assertion1" }
+        assertions { label: "assertion2" }
+        assertions { label: "assertion3" }
+      )pb"))));
+}
+
+TEST(GetMutableManifestForAssertionTest,
+     ReturnsManifestFromIngredientManifest) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest { label: "active_manifest" }
+    ingredient_manifests {
+      label: "ingredient_manifest1"
+      assertions {
+        label: "assertion1"
+        actions {}
+      }
+    }
+    ingredient_manifests {
+      label: "ingredient_manifest2"
+      assertions {
+        label: "assertion1"
+        data_hash {}
+      }
+    }
+  )pb");
+  EXPECT_THAT(
+      GetMutableManifestForAssertion(
+          &result,
+          "self#jumbf=/c2pa/ingredient_manifest2/c2pa.assertions/assertion1"),
+      Pointee(EqualsProto(ParseTextProtoOrDie<Manifest>(R"pb(
+        label: "ingredient_manifest2"
+        assertions {
+          label: "assertion1"
+          data_hash {}
+        }
+      )pb"))));
+}
+
+TEST(GetMutableManifestForAssertionTest, ManifestLabelNotFound) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "active_manifest"
+      assertions { label: "assertion1" }
+    }
+    ingredient_manifests {
+      label: "ingredient_manifest1"
+      assertions {
+        label: "assertion1"
+        actions {}
+      }
+    }
+  )pb");
+  EXPECT_THAT(GetMutableManifestForAssertion(
+                  &result,
+                  "self#jumbf=/c2pa/other_manifest/c2pa.assertions/assertion1"),
+              IsNull());
+}
+
+TEST(GetMutableManifestForAssertionTest, InvalidUriPrefix) {
+  auto result = ParseTextProtoOrDie<PartialValidationResultProto>(R"pb(
+    active_manifest {
+      label: "active_manifest"
+      assertions { label: "assertion1" }
+    }
+  )pb");
+  EXPECT_THAT(GetMutableManifestForAssertion(&result, "invalid_prefix"),
+              IsNull());
+  EXPECT_THAT(
+      GetMutableManifestForAssertion(
+          &result,
+          "self#jumbf=/c2pb/active_manifest/c2pa.assertions/assertion1"),
+      IsNull());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/validator/tracker.cc b/validator/tracker.cc
new file mode 100644
index 0000000..ba65cfd
--- /dev/null
+++ b/validator/tracker.cc
@@ -0,0 +1,156 @@
+// 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 "validator/tracker.h"
+
+#include <string>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/log/check.h"
+#include "absl/strings/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "constants/status_codes.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+namespace {
+
+std::string CreateUrl(absl::string_view manifest_label,
+                      absl::string_view assertion_label,
+                      absl::string_view url) {
+  if (url.empty()) {
+    if (assertion_label.empty()) {
+      return absl::StrCat("self#jumbf=/c2pa/", manifest_label);
+    }
+    return absl::StrCat("self#jumbf=/c2pa/", manifest_label,
+                        "/c2pa.assertions/", assertion_label);
+  }
+  if (absl::StartsWith(url, "self#jumbf=")) {
+    return std::string(url);
+  }
+  return absl::StrCat("self#jumbf=/c2pa/", manifest_label,
+                      kManifestLabelDelimiter, url);
+}
+
+// Returns true if the label is well-formed. Used in DCHECKs to help catch bugs
+// where a URI or path is provided instead of a label.
+bool ValidLabel(absl::string_view label) {
+  return !label.empty() && !absl::StrContains(label, "/");
+}
+
+}  // namespace
+
+ValidationTracker::ValidationTracker(Manifest* manifest)
+    : manifest_label_(manifest->label()),
+      status_set_(manifest->mutable_validation()),
+      id_("root") {
+  DCHECK(ValidLabel(manifest_label_)) << "label: " << manifest_label_;
+}
+
+ValidationTracker::ValidationTracker(absl::string_view manifest_label,
+                                     ValidationStatusSet* status_set)
+    : manifest_label_(manifest_label), status_set_(status_set), id_("root") {
+  DCHECK(ValidLabel(manifest_label_)) << "label: " << manifest_label_;
+}
+
+ValidationTracker ValidationTracker::AssertionTracker(
+    absl::string_view assertion_label) {
+  ValidationTracker new_tracker = *this;
+  new_tracker.assertion_label_ = assertion_label;
+  return new_tracker;
+}
+
+void ValidationTracker::RecordFailure(FailureStatusCode status,
+                                      RecordOptions options) {
+  ValidationStatus* failure = status_set_->mutable_failures()->Add();
+  failure->set_code(absl::StrCat(status));
+  failure->set_url(CreateUrl(manifest_label_, assertion_label_, options.url));
+  if (!options.explanation.empty()) {
+    failure->set_explanation(options.explanation);
+  }
+}
+
+void ValidationTracker::RecordSuccess(SuccessStatusCode status,
+                                      RecordOptions options) {
+  ValidationStatus* success = status_set_->mutable_successes()->Add();
+  success->set_code(absl::StrCat(status));
+  success->set_url(CreateUrl(manifest_label_, assertion_label_, options.url));
+  if (!options.explanation.empty()) {
+    success->set_explanation(options.explanation);
+  }
+}
+
+void ValidationTracker::RecordInformational(InformationalStatusCode status,
+                                            RecordOptions options) {
+  ValidationStatus* informational =
+      status_set_->mutable_informationals()->Add();
+  informational->set_code(absl::StrCat(status));
+  informational->set_url(
+      CreateUrl(manifest_label_, assertion_label_, options.url));
+  if (!options.explanation.empty()) {
+    informational->set_explanation(options.explanation);
+  }
+}
+
+bool ValidationTracker::HasFailures() const {
+  return status_set_->failures_size() > 0;
+}
+
+absl::flat_hash_set<std::string> ValidationTracker::GetFailures() const {
+  absl::flat_hash_set<std::string> failures;
+  for (const auto& failure_status : status_set_->failures()) {
+    failures.insert(std::string(failure_status.code()));
+  }
+  return failures;
+}
+
+absl::flat_hash_set<std::string> ValidationTracker::GetSuccesses() const {
+  absl::flat_hash_set<std::string> successes;
+  for (const auto& success_status : status_set_->successes()) {
+    successes.insert(std::string(success_status.code()));
+  }
+  return successes;
+}
+
+absl::flat_hash_set<std::string> ValidationTracker::GetInformationals() const {
+  absl::flat_hash_set<std::string> informational;
+  for (const auto& informational_status : status_set_->informationals()) {
+    informational.insert(std::string(informational_status.code()));
+  }
+  return informational;
+}
+
+void ValidationTracker::MergeStatuses(const ValidationStatusSet& statuses) {
+  for (const auto& failure : statuses.failures()) {
+    ValidationStatus* f = status_set_->mutable_failures()->Add();
+    *f = failure;
+    f->set_url(CreateUrl(manifest_label_, assertion_label_, failure.url()));
+  }
+  for (const auto& success : statuses.successes()) {
+    ValidationStatus* s = status_set_->mutable_successes()->Add();
+    *s = success;
+    s->set_url(CreateUrl(manifest_label_, assertion_label_, success.url()));
+  }
+  for (const auto& info : statuses.informationals()) {
+    ValidationStatus* i = status_set_->mutable_informationals()->Add();
+    *i = info;
+    i->set_url(CreateUrl(manifest_label_, assertion_label_, info.url()));
+  }
+}
+
+}  // namespace credentio
diff --git a/validator/tracker.h b/validator/tracker.h
new file mode 100644
index 0000000..e6ae751
--- /dev/null
+++ b/validator/tracker.h
@@ -0,0 +1,106 @@
+// 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_VALIDATOR_TRACKER_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_TRACKER_H_
+
+#include <stdbool.h>
+
+#include <string>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "google/protobuf/repeated_ptr_field.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+
+// Tracks the validation process by accumulating the C2PA status codes returned
+// in various validation steps.
+class ValidationTracker {
+ public:
+  // Constructs a new validation tracker for the given manifest.
+  // Status values will be recorded in the given manifest, and the URL of the
+  // manifest itself will be used by default.
+  explicit ValidationTracker(Manifest* manifest);
+
+  // Constructs a new validation tracker for the given manifest label and
+  // status set.
+  explicit ValidationTracker(absl::string_view manifest_label,
+                             ValidationStatusSet* status_set);
+
+  // Returns a tracker that uses the URL of the given assertion by default.
+  // Status values are recorded to the same location as the parent tracker.
+  ValidationTracker AssertionTracker(absl::string_view assertion_label);
+
+  struct RecordOptions {
+    // The url to record with the status. If the url is empty, the manifest url
+    // is used. If the url starts with "self#jumbf=", the supplied url is used.
+    // Otherwise the supplied value is interpreted as a path relative to the
+    // manifest url.
+    absl::string_view url = "";
+    // An optional explanation for the status.
+    absl::string_view explanation = "";
+  };
+
+  // Records a C2PA success status code.
+  void RecordSuccess(SuccessStatusCode status, RecordOptions options);
+
+  // Records a C2PA informational status code.
+  void RecordInformational(InformationalStatusCode status,
+                           RecordOptions options);
+
+  // Records a C2PA failure status code.
+  void RecordFailure(FailureStatusCode status, RecordOptions options);
+
+  // Returns if the validation process has any failures so far.
+  bool HasFailures() const;
+  // Returns the C2PA failure status codes observed so far.
+  absl::flat_hash_set<std::string> GetFailures() const;
+  // Returns the C2PA failure statuses observed so far.
+  google::protobuf::RepeatedPtrField<ValidationStatus> GetFailureStatuses()
+      const {
+    return status_set_->failures();
+  }
+  // Returns the C2PA success status codes observed so far.
+  absl::flat_hash_set<std::string> GetSuccesses() const;
+  // Returns the C2PA success statuses observed so far.
+  google::protobuf::RepeatedPtrField<ValidationStatus> GetSuccessStatuses()
+      const {
+    return status_set_->successes();
+  }
+  // Returns the C2PA informational status codes observed so far.
+  absl::flat_hash_set<std::string> GetInformationals() const;
+  // Returns the C2PA informational statuses observed so far.
+  google::protobuf::RepeatedPtrField<ValidationStatus>
+  GetInformationalStatuses() const {
+    return status_set_->informationals();
+  }
+
+  // Merges the statuses from a ValidationStatusSet into this tracker.
+  void MergeStatuses(const ValidationStatusSet& statuses);
+
+ private:
+  absl::string_view manifest_label_;
+  absl::string_view assertion_label_;
+  ValidationStatusSet* const status_set_;
+  std::string id_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_TRACKER_H_
diff --git a/validator/tracker_test.cc b/validator/tracker_test.cc
new file mode 100644
index 0000000..e0dd484
--- /dev/null
+++ b/validator/tracker_test.cc
@@ -0,0 +1,269 @@
+// 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 "validator/tracker.h"
+
+#include "absl/strings/string_view.h"
+#include "constants/status_codes.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+using ::testing::Property;
+
+constexpr absl::string_view kManifestLabel = "urn:c2pa:9999";
+
+TEST(ValidationTrackerTest, RecordSuccess) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  validation_tracker.RecordSuccess(SuccessStatusCode::kClaimSignatureValidated,
+                                   {});
+  EXPECT_FALSE(validation_tracker.HasFailures());
+  EXPECT_THAT(validation_tracker.GetSuccesses(),
+              ElementsAre("claimSignature.validated"));
+  EXPECT_THAT(validation_tracker.GetSuccessStatuses(),
+              ElementsAre(Property(&ValidationStatus::code,
+                                   "claimSignature.validated")));
+  EXPECT_THAT(validation_tracker.GetFailures(), IsEmpty());
+  EXPECT_THAT(validation_tracker.GetInformationals(), IsEmpty());
+  EXPECT_THAT(manifest.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(
+                  R"pb(successes {
+                         code: "claimSignature.validated"
+                         url: "self#jumbf=/c2pa/urn:c2pa:9999"
+                       })pb")));
+}
+
+TEST(ValidationTrackerTest, RecordInformational) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  validation_tracker.RecordInformational(
+      InformationalStatusCode::kIngredientUnknownProvenance, {});
+  EXPECT_FALSE(validation_tracker.HasFailures());
+  EXPECT_THAT(validation_tracker.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(validation_tracker.GetSuccessStatuses(), IsEmpty());
+  EXPECT_THAT(validation_tracker.GetInformationals(),
+              ElementsAre("ingredient.unknownProvenance"));
+  EXPECT_THAT(validation_tracker.GetInformationalStatuses(),
+              ElementsAre(Property(&ValidationStatus::code,
+                                   "ingredient.unknownProvenance")));
+  EXPECT_THAT(validation_tracker.GetFailures(), IsEmpty());
+  EXPECT_THAT(manifest.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(
+                  R"pb(informationals {
+                         code: "ingredient.unknownProvenance"
+                         url: "self#jumbf=/c2pa/urn:c2pa:9999"
+                       })pb")));
+}
+
+TEST(ValidationTrackerTest, RecordInformationalWithMessage) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  validation_tracker.RecordInformational(
+      InformationalStatusCode::kIngredientUnknownProvenance,
+      {.explanation = "some message"});
+  EXPECT_FALSE(validation_tracker.HasFailures());
+  EXPECT_THAT(validation_tracker.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(validation_tracker.GetSuccessStatuses(), IsEmpty());
+  EXPECT_THAT(validation_tracker.GetInformationals(),
+              ElementsAre("ingredient.unknownProvenance"));
+  EXPECT_THAT(validation_tracker.GetFailures(), IsEmpty());
+  EXPECT_THAT(manifest.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(
+                  R"pb(informationals {
+                         code: "ingredient.unknownProvenance"
+                         url: "self#jumbf=/c2pa/urn:c2pa:9999"
+                         explanation: "some message"
+                       })pb")));
+}
+
+TEST(ValidationTrackerTest, RecordFailure) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing,
+                                   {});
+  EXPECT_TRUE(validation_tracker.HasFailures());
+  EXPECT_THAT(validation_tracker.GetSuccesses(), IsEmpty());
+  EXPECT_THAT(validation_tracker.GetSuccessStatuses(), IsEmpty());
+  EXPECT_THAT(validation_tracker.GetFailures(),
+              ElementsAre("claimSignature.missing"));
+  EXPECT_THAT(validation_tracker.GetInformationals(), IsEmpty());
+  EXPECT_THAT(manifest.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(
+                  R"pb(failures {
+                         code: "claimSignature.missing"
+                         url: "self#jumbf=/c2pa/urn:c2pa:9999"
+                       })pb")));
+}
+
+TEST(ValidationTrackerTest, RecordFailureWithMessage) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  validation_tracker.RecordFailure(FailureStatusCode::kClaimMissing, {});
+  validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing,
+                                   {.explanation = "some message"});
+  EXPECT_TRUE(validation_tracker.HasFailures());
+  EXPECT_THAT(
+      validation_tracker.GetFailureStatuses(),
+      ElementsAre(Property(&ValidationStatus::code, "claim.missing"),
+                  Property(&ValidationStatus::code, "claimSignature.missing")));
+  EXPECT_THAT(
+      manifest.validation(),
+      EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb(
+        failures { code: "claim.missing" url: "self#jumbf=/c2pa/urn:c2pa:9999" }
+        failures {
+          code: "claimSignature.missing"
+          url: "self#jumbf=/c2pa/urn:c2pa:9999"
+          explanation: "some message"
+        }
+      )pb")));
+}
+
+TEST(ValidationTrackerTest, AssertionTracker) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  ValidationTracker assertion_tracker =
+      validation_tracker.AssertionTracker("testlabel.v2");
+  assertion_tracker.RecordFailure(
+      FailureStatusCode::kAssertionActionMalformed,
+      {.url = "self#jumbf=/url/override", .explanation = "message1"});
+  assertion_tracker.RecordFailure(FailureStatusCode::kAssertionActionMalformed,
+                                  {.explanation = "message2"});
+  EXPECT_TRUE(assertion_tracker.HasFailures());
+  EXPECT_TRUE(validation_tracker.HasFailures());
+  EXPECT_THAT(
+      assertion_tracker.GetFailureStatuses(),
+      ElementsAre(
+          Property(&ValidationStatus::code, "assertion.action.malformed"),
+          Property(&ValidationStatus::code, "assertion.action.malformed")));
+  EXPECT_THAT(
+      manifest.validation(),
+      EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb(
+        failures {
+          code: "assertion.action.malformed"
+          url: "self#jumbf=/url/override"
+          explanation: "message1"
+        }
+        failures {
+          code: "assertion.action.malformed"
+          url: "self#jumbf=/c2pa/urn:c2pa:9999/c2pa.assertions/testlabel.v2"
+          explanation: "message2"
+        }
+      )pb")));
+}
+
+TEST(ValidationTrackerTest, HasFailures) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  validation_tracker.RecordFailure(FailureStatusCode::kClaimSignatureMissing,
+                                   {});
+  validation_tracker.RecordSuccess(SuccessStatusCode::kClaimSignatureValidated,
+                                   {});
+  EXPECT_TRUE(validation_tracker.HasFailures());
+}
+
+TEST(ValidationTrackerTest, NothingRecorded) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  {
+    ValidationTracker validation_tracker(&manifest);
+  }
+  EXPECT_TRUE(manifest.has_validation());
+}
+
+TEST(ValidationTrackerTest, RecordSuccessFullUrl) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  validation_tracker.RecordSuccess(SuccessStatusCode::kClaimSignatureValidated,
+                                   {.url = "self#jumbf=/c2pa/urn:c2pa:1234"});
+  EXPECT_THAT(manifest.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(
+                  R"pb(successes {
+                         code: "claimSignature.validated"
+                         url: "self#jumbf=/c2pa/urn:c2pa:1234"
+                       })pb")));
+}
+
+TEST(ValidationTrackerTest, RecordSuccessPartialUrl) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+  validation_tracker.RecordSuccess(SuccessStatusCode::kClaimSignatureValidated,
+                                   {.url = "suffix"});
+  EXPECT_THAT(manifest.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(
+                  R"pb(successes {
+                         code: "claimSignature.validated"
+                         url: "self#jumbf=/c2pa/urn:c2pa:9999/suffix"
+                       })pb")));
+}
+
+TEST(ValidationTrackerTest, MergeStatuses) {
+  Manifest manifest;
+  manifest.set_label(kManifestLabel);
+  ValidationTracker validation_tracker(&manifest);
+
+  ValidationStatusSet statuses;
+  ValidationStatus* failure = statuses.add_failures();
+  failure->set_code("claim.missing");
+  failure->set_url("some/url");
+  failure->set_explanation("some explanation");
+
+  ValidationStatus* success = statuses.add_successes();
+  success->set_code("claimSignature.validated");
+  success->set_url("self#jumbf=/c2pa/other/url");
+
+  ValidationStatus* informational = statuses.add_informationals();
+  informational->set_code("ingredient.unknownProvenance");
+
+  validation_tracker.MergeStatuses(statuses);
+
+  EXPECT_THAT(manifest.validation(),
+              EqualsProto(ParseTextProtoOrDie<ValidationStatusSet>(R"pb(
+                failures {
+                  code: "claim.missing"
+                  url: "self#jumbf=/c2pa/urn:c2pa:9999/some/url"
+                  explanation: "some explanation"
+                }
+                successes {
+                  code: "claimSignature.validated"
+                  url: "self#jumbf=/c2pa/other/url"
+                }
+                informationals {
+                  code: "ingredient.unknownProvenance"
+                  url: "self#jumbf=/c2pa/urn:c2pa:9999"
+                }
+              )pb")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/validator/validation_result_internal.cc b/validator/validation_result_internal.cc
new file mode 100644
index 0000000..4a88fa2
--- /dev/null
+++ b/validator/validation_result_internal.cc
@@ -0,0 +1,68 @@
+// 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 "validator/validation_result_internal.h"
+
+#include <memory>
+
+#include "absl/algorithm/container.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<ValidationResultProto>> MakeFullValidationResult(
+    std::unique_ptr<PartialValidationResultProto> partial_result) {
+  if (partial_result->active_manifest().validation().failures().empty()) {
+    if (!absl::c_any_of(
+            partial_result->active_manifest().validation().successes(),
+            [&](ValidationStatus status) {
+              if (status.code() == "com.google.assertion.dataHash.match" ||
+                  status.code() == "com.google.assertion.bmffHash.match" ||
+                  status.code() == "com.google.assertion.boxesHash.match" ||
+                  status.code() ==
+                      "com.google.assertion.collectionHash.match" ||
+                  status.code() == "assertion.dataHash.match" ||
+                  status.code() == "assertion.bmffHash.match" ||
+                  status.code() == "assertion.boxesHash.match" ||
+                  status.code() == "assertion.collectionHash.match") {
+                return true;
+              }
+              return false;
+            })) {
+      return absl::InternalError(
+          "No failures encountered during validation but no hard binding "
+          "assertion match was recorded either.");
+    }
+  }
+
+  auto result = std::make_unique<ValidationResultProto>();
+  result->mutable_active_manifest()->Swap(
+      partial_result->mutable_active_manifest());
+  result->mutable_ingredient_manifests()->Swap(
+      partial_result->mutable_ingredient_manifests());
+  if (partial_result->has_spec_version()) {
+    result->set_spec_version(partial_result->spec_version());
+  }
+  if (partial_result->has_trust_list_uri()) {
+    result->set_trust_list_uri(partial_result->trust_list_uri());
+  }
+  return result;
+}
+
+}  // namespace credentio
diff --git a/validator/validation_result_internal.h b/validator/validation_result_internal.h
new file mode 100644
index 0000000..188efd4
--- /dev/null
+++ b/validator/validation_result_internal.h
@@ -0,0 +1,33 @@
+// 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_VALIDATOR_VALIDATION_RESULT_INTERNAL_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATION_RESULT_INTERNAL_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "proto/validation_result.pb.h"
+
+namespace credentio {
+
+// Creates a full validation result from a partial validation result, or returns
+// an error if the partial validation result is malformed/incomplete.
+absl::StatusOr<std::unique_ptr<ValidationResultProto>> MakeFullValidationResult(
+    std::unique_ptr<PartialValidationResultProto> partial_result);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATION_RESULT_INTERNAL_H_
diff --git a/validator/validation_result_internal_test.cc b/validator/validation_result_internal_test.cc
new file mode 100644
index 0000000..07ca304
--- /dev/null
+++ b/validator/validation_result_internal_test.cc
@@ -0,0 +1,75 @@
+// 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 "validator/validation_result_internal.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAre;
+using ::testing::Eq;
+
+TEST(MakeFullValidationResult_Failure, MakeFullValidationResult) {
+  auto partial_result = std::make_unique<PartialValidationResultProto>();
+  partial_result->mutable_active_manifest()->set_label("active");
+  partial_result->add_ingredient_manifests()->set_label("ingredient1");
+  partial_result->add_ingredient_manifests()->set_label("ingredient2");
+  EXPECT_THAT(MakeFullValidationResult(std::move(partial_result)),
+              StatusIs(absl::StatusCode::kInternal));
+}
+
+TEST(MakeFullValidationResult_Success, MakeFullValidationResult) {
+  auto partial_result = std::make_unique<PartialValidationResultProto>();
+  partial_result->mutable_active_manifest()->set_label("active");
+  partial_result->mutable_active_manifest()
+      ->mutable_validation()
+      ->add_successes()
+      ->set_code("com.google.assertion.dataHash.match");
+  partial_result->add_ingredient_manifests()->set_label("ingredient1");
+  partial_result->add_ingredient_manifests()->set_label("ingredient2");
+  partial_result->set_spec_version("2.4.0");
+  partial_result->set_trust_list_uri("urn:google:c2pa:trust-list:test");
+  auto res = MakeFullValidationResult(std::move(partial_result));
+  ASSERT_TRUE(res.ok());
+  auto result = std::move(*res);
+  EXPECT_THAT(result->active_manifest().label(), Eq("active"));
+  EXPECT_THAT(result->ingredient_manifests(),
+              ElementsAre(EqualsProto(ParseTextProtoOrDie<Manifest>(R"pb(
+                            label: "ingredient1"
+                          )pb")),
+                          EqualsProto(ParseTextProtoOrDie<Manifest>(R"pb(
+                            label: "ingredient2"
+                          )pb"))));
+  EXPECT_THAT(result->spec_version(), Eq("2.4.0"));
+  EXPECT_THAT(result->trust_list_uri(), Eq("urn:google:c2pa:trust-list:test"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/validator/validator_metrics.h b/validator/validator_metrics.h
new file mode 100644
index 0000000..a4b5761
--- /dev/null
+++ b/validator/validator_metrics.h
@@ -0,0 +1,40 @@
+// 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_VALIDATOR_VALIDATOR_METRICS_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_METRICS_H_
+
+#include "absl/strings/string_view.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+
+namespace credentio {
+// Interface for recording metrics for C2PA validation.
+class ValidatorMetrics {
+ public:
+  ValidatorMetrics() = default;
+  virtual ~ValidatorMetrics() = default;
+  // Records metrics for a partial validation result (without binding checks).
+  virtual void RecordPartialValidationResult(
+      const PartialValidationResultProto& result) const = 0;
+  // Records metrics for a full validation result.
+  virtual void RecordValidationResult(
+      absl::string_view media_type,
+      const ValidationResultProto& result) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_METRICS_H_
diff --git a/validator/validator_options.h b/validator/validator_options.h
new file mode 100644
index 0000000..8e5d5cc
--- /dev/null
+++ b/validator/validator_options.h
@@ -0,0 +1,67 @@
+// 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_VALIDATOR_VALIDATOR_OPTIONS_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_OPTIONS_H_
+
+#include <cstdint>
+#include <memory>
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/time/clock_interface.h"
+#include "constants/spec_version.h"
+#include "crypto/crypto_read_handler.h"
+#include "validator/validator_metrics.h"
+
+namespace credentio {
+
+// Options controlling validator behavior.
+struct ValidatorOptions {
+  // REQUIRED. Crypto read handler to use.
+  std::unique_ptr<CryptoReadHandler> absl_nonnull crypto_read_handler;
+
+  // Clock to use (e.g., for certificate validity checks).
+  absl::Clock* clock = &absl::Clock::GetRealClock();
+
+  // Maximum size of chunks read from input files.
+  //
+  // If the C2PA metadata (manifest store) is larger than this limit, validation
+  // will fail.
+  int64_t max_chunk_bytes = 10 * 1024 * 1024;  // 10 MiB
+
+  // Enables the validator to process legacy manifests based on C2PA standards
+  // prior to version 2.1 (i.e. those with labels that do not begin with the
+  // prefix introduced in v2.1, `urn:c2pa`). This option bypasses the label
+  // check and the redaction of custom, deprecated, and gathered assertions;
+  // various pre-2.1 features are not supported. This option should only be used
+  // for testing.
+  // NOTE: This option is for testing only and cannot be used with
+  // CreateValidator.
+  bool accept_legacy_manifest_for_test = false;
+
+  // Whether to collect metrics.
+  ValidatorMetrics* absl_nullable metrics = nullptr;
+
+  // The C2PA specification version the product conforms to.
+  SpecVersion spec_version = SpecVersion::kUnspecified;
+
+  // URI reference (including version/timestamp) to the trust list used.
+  std::string trust_list_uri;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_VALIDATOR_OPTIONS_H_